hermes-agent/hermes_cli/update_lock.py
Brooklyn Nicholson 8c76fe19f8 fix(update): let the GUI updater's hermes update child pass the lock it already holds
The cross-process update lock (fe8e4d93d) made the in-progress marker
mutually exclusive across every update entrypoint — but the Tauri
updater holds that marker for its WHOLE run and then spawns
hermes update as a child stage. The child read the marker, found its
own parent's live pid, refused with exit 2, and the GUI mapped that to
"Hermes is still running. Close all Hermes windows and try the update
again." Retry spawns a fresh updater that deadlocks against itself the
same way, so every GUI-driven update dead-ends on the failure screen
with no winnable retry (observed: three consecutive self-refusals in
bootstrap-installer.log within 90 seconds).

Hand the claim off explicitly: update_child_env exports
HERMES_UPDATE_HANDOFF_PID naming the updater's own pid, and
UpdateLock.acquire treats a live holder matching that pid as the lock
we are already running under — run without claiming, and release
leaves the parent's marker untouched. The env var alone grants
nothing: the pid must also be the live marker owner, so a stale or
forged value cannot bypass the lock, and a dashboard-spawned
hermes update (no handoff env) is still refused exactly as before.
2026-07-30 00:51:33 -05:00

254 lines
10 KiB
Python

"""Cross-process mutual exclusion for in-flight Hermes updates.
Three different surfaces can start an update of the same install tree:
* ``hermes update`` from a terminal,
* the dashboard's Update button (``POST /api/hermes/update`` →
``_spawn_hermes_action(["update"])``, detached),
* the desktop's Update button, which hands off to the Tauri
``hermes-setup --update`` and, on its failure screen, to install-mode
bootstrap (``install.ps1`` / ``install.sh``).
Until now only the Tauri updater published an "update in progress" marker
(``UpdateMarkerGuard`` in ``apps/bootstrap-installer/src-tauri/src/update.rs``),
and only the Electron desktop consumed it (``electron/update-marker.ts``, to
gate local backend startup). Nothing stopped two *updaters* from running at
once — so a dashboard-spawned ``hermes update`` and an installer-driven
``git checkout`` could mutate the same checkout concurrently, rewriting source
under a live interpreter and leaving the tree half-updated.
This module makes that same marker the single lock for **all** update
entrypoints instead of adding a fourth mechanism. Format and location are
unchanged and remain byte-compatible with the Rust and Electron readers:
<HERMES_HOME>/.hermes-update-in-progress body: "<pid>\\n<started_at_unix>"
A marker only counts as a live update when its pid is alive AND it is younger
than :data:`UPDATE_MARKER_MAX_AGE_MS` — mirroring ``readLiveUpdateMarker`` so a
crashed updater self-heals instead of wedging every future update. A stale
marker is removed on read by whoever notices it first.
One layering wrinkle: the Tauri updater holds this marker for its WHOLE run and
then spawns ``hermes update`` as a child stage. Without a handoff the child
sees its own parent's live marker and refuses — the GUI update deadlocks
against itself on every attempt ("Hermes is still running", retry forever).
The updater therefore exports :data:`HANDOFF_PID_ENV` naming its own pid, and
``acquire`` treats a live holder matching that pid as the lock we are already
running under. The env var alone grants nothing: the pid must also be the
live marker owner, so a stale or forged value cannot bypass the lock.
"""
from __future__ import annotations
import logging
import os
import time
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 — the same marker is read by both, and
# 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 (the Tauri `hermes-setup --update` flow) 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 refuses its
# own parent's lock and the GUI update can never complete. See update_child_env
# in apps/bootstrap-installer/src-tauri/src/update.rs — keep the name in sync.
HANDOFF_PID_ENV = "HERMES_UPDATE_HANDOFF_PID"
# Exit code meaning "another updater/instance owns this install right now".
# Already the de-facto contract: the Windows shim + venv-holder guards in
# _cmd_update_impl exit 2, and the Tauri updater matches on it
# (UPDATE_EXIT_CONCURRENT in apps/bootstrap-installer/src-tauri/src/update.rs)
# to show "Hermes is still running" instead of a generic failure. Naming it
# here keeps the concurrent-update refusal on that same understood contract.
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. A profile-scoped path
here would put the lock somewhere the other two owners never look.
"""
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`, the project's existing
no-kill probe. Do NOT hand-roll this with ``os.kill(pid, 0)``: on Windows
that is not a no-op — CPython routes ``sig=0`` to
``GenerateConsoleCtrlEvent``, which Ctrl+C's the target's whole console
process group (bpo-14484). A liveness check that killed the updater it was
asking about would be a spectacular way to fix a concurrency bug.
Any pid we cannot evaluate counts as dead: a corrupt marker must not wedge
the lock forever.
"""
if pid <= 0:
return False
try:
from gateway.status import _pid_exists
return bool(_pid_exists(pid))
except Exception as exc:
# Import failure or an unusable pid (e.g. larger than the platform's
# pid_t). Treat the marker as stale rather than blocking updates.
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, if any.
Read from :data:`HANDOFF_PID_ENV`. Malformed values count as absent —
a broken handoff must fall back to the normal refusal, never crash.
"""
raw = os.environ.get(HANDOFF_PID_ENV, "").strip()
if not raw:
return None
try:
pid = int(raw)
except ValueError:
return None
return pid if pid > 0 else None
@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:
raw = marker.read_text(encoding="utf-8")
except OSError:
return None # absent or unreadable => no live update
lines = raw.splitlines()
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:
try:
marker.unlink()
except OSError:
pass
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 (PID {holder.pid}, "
f"started {elapsed} ago).\n"
"\n"
" Two updates mutating the same checkout corrupt it: one rewrites\n"
" source while the other is mid-install. Wait for it to finish, or\n"
" close the window/dashboard tab that started it, then retry."
)
class UpdateLock:
"""Context manager owning the shared update marker for this process.
``acquired`` is False when another live update already holds it — callers
decide whether that's a hard refusal (CLI/dashboard) or a wait. Releasing
only removes the marker when *we* still own it, so a marker rewritten by a
handoff partner (the Tauri updater overwrites it with its own pid) is never
deleted out 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` is our own
orchestrating parent (the Tauri updater spawning `hermes update` as a
stage): we run under ITS claim rather than refusing or re-writing the
marker, and ``release`` leaves the parent's marker untouched.
"""
existing = read_live_update(path=self.path)
if existing is not None:
if existing.pid == _handoff_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, exactly like the Rust guard: an unwritable marker
# must not block the update itself (that would be a worse failure
# than the race it prevents). Degrade to the 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:
raw = self.path.read_text(encoding="utf-8")
owner = int(raw.splitlines()[0].strip())
except (OSError, IndexError, ValueError):
return
if owner != os.getpid():
# A handoff partner took ownership (e.g. the Tauri updater wrote
# its own pid). Leave it alone — it's still a live update.
return
try:
self.path.unlink()
except OSError:
pass
def __enter__(self) -> "UpdateLock":
self.acquire()
return self
def __exit__(self, *_exc) -> None:
self.release()