Files
hermes-agent/hermes_cli/desktop_build_lock.py
Maciej Lisiewski 2ff58849f4 ci: ruff UP037 fixes
2026-10-09 17:34:38 +02:00

157 lines
6.4 KiB
Python

"""Cross-process serialization for the mutable Desktop build preflight."""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from typing import IO
class DesktopBuildLock:
"""Advisory lock held while npm installs and packages Hermes Desktop.
``node_modules`` and ``apps/desktop/release`` are checkout-scoped even
when two commands use different Hermes profiles. The lock therefore
lives under the profile-common Hermes root and is keyed by the resolved
checkout path. Keeping it out of the checkout preserves ``--skip-build``
launches from read-only/prebuilt source trees. The open handle owns the
lock, so the OS releases it automatically if a builder crashes.
The build also writes the checkout an update writes, so the lock first takes the
checkout lock every updater holds (``update_lock``; review C5), then its own: one lock
order (checkout, then desktop build) for a direct ``hermes desktop`` build and for the
update's build (which already holds the checkout lock and only joins it again), and no
gap in which an updater could take the checkout while npm still writes. Holding the
checkout lock also puts the build in the update custody (``update_custody``): its
descendants hold the checkout until they exit.
"""
def __init__(self, project_root: Path) -> None:
from hermes_constants import get_default_hermes_root
resolved = os.path.normcase(str(project_root.resolve(strict=False)))
checkout_key = hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:24]
self.path = get_default_hermes_root() / "locks" / f"desktop-build-{checkout_key}.lock"
self.project_root = project_root
self._handle: IO[str] | None = None
self._checkout = None
def acquire(self, *, wait: bool = False) -> bool:
"""Acquire the lock; queue behind the current holder when ``wait``.
Returns ``False`` only when another process owns the lock and ``wait``
is unset. Filesystem errors propagate so callers fail explicitly
instead of silently falling back to the corrupting concurrent
behavior.
"""
if self._handle is not None:
return True
if not self._acquire_checkout(wait=wait):
return False
try:
acquired = self._acquire_build_lock(wait=wait)
except BaseException:
self._release_checkout()
raise
if not acquired:
self._release_checkout()
return acquired
def _acquire_checkout(self, *, wait: bool) -> bool:
"""Take (or, inside an update, join) the checkout lock before the build lock (C5)."""
import time
from hermes_cli import update_lock
held, wanted = update_lock._HELD, update_lock.checkout_lock_path(self.project_root)
if held is not None and held["path"] != str(wanted):
# This process holds a checkout lock under another path. Another spelling of this
# checkout's lock file (a symlinked install root) is this update's own custody: build
# inside it, since a second acquire would wait on its own lock. Any other checkout's
# lock proves nothing about this one (review Q2), and a process records one hold only.
if not (wanted.exists() and os.path.samefile(held["path"], wanted)):
raise OSError(f"this process holds the update lock of another checkout ({held['path']}), "
f"so it cannot also lock {self.project_root} for its build")
return True
lock = update_lock.UpdateLock(install_root=self.project_root, checkout_first=False)
announced = False
while not lock.acquire_checkout(self.project_root):
if lock.holder is not None and lock.holder.reason:
raise OSError(lock.holder.reason) # never build unserialized; the caller says so
if not wait:
return False
if not announced:
print("→ Waiting for a running Hermes update to release this checkout...")
announced = True
time.sleep(1.0)
self._checkout = lock
return True
def _release_checkout(self) -> None:
lock, self._checkout = self._checkout, None
if lock is not None:
lock.release()
def _acquire_build_lock(self, *, wait: bool) -> bool:
from gateway.status import _try_acquire_file_lock
self.path.parent.mkdir(parents=True, exist_ok=True)
handle = self.path.open("a+", encoding="utf-8")
if _try_acquire_file_lock(handle):
self._handle = handle
return True
if not wait:
handle.close()
return False
# Blocking mode queues behind the holder; say so instead of appearing
# to hang with no output (the update path would rather wait for the
# in-flight build it depends on than fail the update).
print("→ Waiting for another Hermes desktop dependency install or build to finish...")
print(f" Lock: {self.path}")
from gateway.status import _release_file_lock
try:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
except ImportError:
# Windows has no fcntl; gateway.status's non-blocking try below is
# the best-effort serialization there (msvcrt.locking), so poll it.
import time
while True:
if _try_acquire_file_lock(handle):
self._handle = handle
return True
_release_file_lock(handle)
time.sleep(1.0)
self._handle = handle
return True
def release(self) -> None:
"""Release the lock when held. Safe to call more than once."""
handle = self._handle
if handle is None:
return
self._handle = None
try:
from gateway.status import _release_file_lock
_release_file_lock(handle)
finally:
try:
handle.close()
finally:
self._release_checkout() # the narrower lock first: the order is checkout, build
def __enter__(self) -> DesktopBuildLock:
if not self.acquire():
raise RuntimeError("desktop build lock is already held")
return self
def __exit__(self, *_exc: object) -> None:
self.release()