"""Cross-process mutual exclusion for in-flight Hermes updates. Two artifacts, one authority each: * The update marker ``/.hermes-update-in-progress`` (contract C1, format v2) is shared with the Tauri updater (``UpdateMarkerGuard`` in ``apps/bootstrap-installer/src-tauri/src/update.rs``), the Electron gate (``electron/update-marker.ts``) and the Desktop hand-off scripts. Body:: \\n\\nct:\\n[delegate: ct:\\n] An owner is live while its pid is alive and its creation time still matches: never by age (the 20-minute ceiling only ages out v1 markers, which carry no creation time). * The checkout lock ``/hermes-update.lock`` (``/.hermes-update.lock`` for a ZIP install with no ``.git``) — a kernel lock (flock / msvcrt) that ``hermes update`` holds for its whole process tree, so two updates of one checkout started from different homes exclude each other and a killed updater whose completion child still runs keeps the checkout locked until that child exits. In the git dir it is never a worktree file: no ``git status``/autostash sees it, whatever the checked-out tree's ``.gitignore`` says. """ from __future__ import annotations import calendar import errno import logging import os import re import secrets import subprocess import sys import threading import time from contextlib import contextmanager, suppress from dataclasses import dataclass from pathlib import Path from stat import S_ISREG logger = logging.getLogger(__name__) # Applies to v1 markers only (no creation-time line): their pid may have been reused, and a # ceiling is the only way such a marker self-heals. A v2 owner is live for as long as it runs. UPDATE_MARKER_MAX_AGE_SECONDS = 20 * 60 # Clock skew allowed between a recorded and a probed process creation time (C1 rule 3). CREATE_TIME_TOLERANCE_SECONDS = 2.0 # Our own creation time re-probed by the same clock: only the marker's 3-decimal rounding differs, # while two processes are at least one scheduler tick (10 ms) apart. _OWN_CREATE_TIME_EPSILON = 0.005 # macOS shells read a creation time from `ps -o lstart` and write it as whole seconds (`ct:N.000`), # truncated. A whole-second claim inside the second we started in is still our incarnation. _WHOLE_SECOND_CT = 1.0 # A claim published by create-then-write (filesystems without hard links) is briefly empty; an # empty marker this young is a claim in flight, not a dead one (contract A3). EMPTY_MARKER_GRACE_SECONDS = 5.0 MARKER_NAME = ".hermes-update-in-progress" CHECKOUT_LOCK_NAME = ".hermes-update.lock" GIT_CHECKOUT_LOCK_NAME = "hermes-update.lock" # 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" # Bound on the parent chain walked by _is_ancestor_pid. Real ancestries are a # handful of links (init -> desktop -> staged updater -> shim -> us); the cap # only exists so an unexpected chain can never spin the walk. _MAX_ANCESTRY_DEPTH = 128 # 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 # msvcrt locks a byte range; lock one byte far past the holder record so other processes can # still read who holds it (a locked range is unreadable to them on Windows). _WINDOWS_LOCK_OFFSET = 1 << 20 # R5b: the lease bytes just past it. A Windows process that joins its ancestor's lock (it cannot # inherit it) also locks one lease byte of its own: a completion child the job refused runs # outside the kill-on-close job and outlives a killed owner, and its lease keeps the checkout # busy until it exits. Takers and probes treat any held lease as a held lock. _LEASE_SLOTS = 16 _FILETIME_UNIX_EPOCH = 116444736000000000 def update_marker_path() -> Path: """Path of the shared update marker: always the profile-tree ROOT home. A sticky or ``-p`` profile re-homes ``HERMES_HOME`` to ``/profiles/

``; the Desktop, the hand-off scripts and the Tauri updater all look at the root, so a profile-scoped marker would be one the other owners never see. """ try: from hermes_constants import get_default_hermes_root except ImportError: # a partial tree (an -I -S completion child of a stubbed checkout) home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes") root = home.parent.parent if home.parent.name == "profiles" else home return root / MARKER_NAME return get_default_hermes_root() / MARKER_NAME def _default_install_root() -> Path: return Path(__file__).resolve().parents[1] def _git_common_dir(root: Path) -> Path | None: """The repository's common git dir, read from disk (no git process: -I -S children). ``.git`` is the dir itself, or a ``gitdir: `` file (linked worktree, submodule) whose target may name the shared dir in ``commondir``. ``None`` when ``root`` is no checkout. """ dot = root / ".git" try: if dot.is_dir(): gitdir = dot elif dot.is_file(): text = dot.read_text(encoding="utf-8-sig").strip() if not text.startswith("gitdir:"): return None gitdir = root / text[len("gitdir:"):].strip() # an absolute target replaces root else: return None common = gitdir / "commondir" if common.is_file(): gitdir = gitdir / common.read_text(encoding="utf-8-sig").strip() except OSError: return None return Path(os.path.normpath(gitdir)) def checkout_lock_path(install_root: Path | str | None = None) -> Path: root = Path(install_root or _default_install_root()) common = _git_common_dir(root) return root / CHECKOUT_LOCK_NAME if common is None else common / GIT_CHECKOUT_LOCK_NAME def _pid_alive(pid: int) -> bool: """Use the dependency-free, Windows-safe, zombie-aware probe before PM is available.""" if pid <= 0: return False try: from hermes_cli._early_recovery import _pid_is_running return _pid_is_running(pid) except Exception as exc: logger.debug("Could not probe pid %s: %s", pid, exc) return False # --- process creation time --------------------------------------------------------------- def _windows_kernel32(): import ctypes from ctypes import wintypes kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] kernel32.OpenProcess.restype = wintypes.HANDLE kernel32.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 kernel32.GetProcessTimes.restype = wintypes.BOOL kernel32.CloseHandle.argtypes = [wintypes.HANDLE] kernel32.CloseHandle.restype = wintypes.BOOL return kernel32 def _windows_create_filetime(pid: int) -> int | None: """GetProcessTimes creation FILETIME (100 ns ticks since 1601) or ``None``.""" import ctypes from ctypes import wintypes kernel32 = _windows_kernel32() handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION if not handle: return None try: times = [wintypes.FILETIME() for _ in range(4)] if not kernel32.GetProcessTimes(handle, *(ctypes.byref(t) for t in times)): return None return (times[0].dwHighDateTime << 32) | times[0].dwLowDateTime finally: kernel32.CloseHandle(handle) def _stdlib_create_time(pid: int) -> float | None: """Creation time in unix seconds without psutil (``-I -S`` children, early recovery). Same clock psutil reports: Linux ``starttime / CLK_TCK + btime``, macOS the kernel start time (``ps -o lstart=`` in UTC, second resolution — inside the 2 s tolerance), Windows the ``GetProcessTimes`` creation FILETIME. """ try: if sys.platform == "win32": ticks = _windows_create_filetime(pid) return None if ticks is None else (ticks - _FILETIME_UNIX_EPOCH) / 1e7 if os.path.isdir("/proc"): with open(f"/proc/{pid}/stat", "rb") as fh: stat = fh.read() start_ticks = int(stat[stat.rindex(b")") + 2:].split()[19]) with open("/proc/stat", "rb") as fh: btime = next(int(line.split()[1]) for line in fh if line.startswith(b"btime ")) return btime + start_ticks / os.sysconf("SC_CLK_TCK") # UTC wall clock: a local-time lstart is ambiguous in the repeated DST hour. out = subprocess.run( ["ps", "-o", "lstart=", "-p", str(pid)], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, stdin=subprocess.DEVNULL, env={"PATH": os.environ.get("PATH") or "/bin:/usr/bin", "LC_ALL": "C", "TZ": "UTC0"}, check=False, ).stdout.strip() if not out: return None return float(calendar.timegm(time.strptime(" ".join(out.split()), "%a %b %d %H:%M:%S %Y"))) except (OSError, ValueError, IndexError, StopIteration, subprocess.SubprocessError, AttributeError): return None def process_create_time(pid: int | None = None) -> float | None: """Creation time of ``pid`` (default: this process) in unix seconds, or ``None``. psutil when importable — the value ``process_identity._process_create_time`` records — else the stdlib probe of the same kernel clock, so a marker written by either is comparable by the other within :data:`CREATE_TIME_TOLERANCE_SECONDS`. """ target = os.getpid() if pid is None else pid try: import psutil except ImportError: return _stdlib_create_time(target) try: return float(psutil.Process(target).create_time()) except Exception: # health: allow BLE001 -- liveness probe: any psutil failure degrades to the stdlib probe return _stdlib_create_time(target) def _own_create_time() -> float | None: """This process's creation time (cached per pid: a forked child is another process).""" pid = os.getpid() if _OWN_CT.get("pid") != pid: _OWN_CT.update(pid=pid, ct=process_create_time(pid)) return _OWN_CT.get("ct") _OWN_CT: dict = {} def _as_ct(recorded) -> float | None: """A recorded creation time as a float: a number, ``"ct:"`` / ``""`` text, or None.""" if recorded is None or isinstance(recorded, (int, float)): return None if recorded is None else float(recorded) match = _CT_VALUE.fullmatch(str(recorded).strip().removeprefix("ct:")) return float(match.group(0)) if match else None def incarnation_live(pid: int, recorded_ct=None) -> bool | None: """THE one incarnation rule (A7 rule 4) for every Python identity reader (marker, pause record). An identity is (pid, creation time). ``True``: that process is running. ``False``: no such process, a different incarnation of the pid, or a claim naming OUR pid that is not us — our own pid is ours only within :data:`_OWN_CREATE_TIME_EPSILON` of our creation time, and a claim without a creation time naming our pid is a previous incarnation (a fresh pid namespace hands a killed update's pid to the next launch) — unless our own creation time is unreadable: then we write no-ct claims ourselves, so a no-ct claim is ours and a ct one is not (``marker.rs`` agrees). ``None``: alive but unprovable — no creation time recorded, or the live one unreadable (Windows denies elevated/other-user pids); each caller applies its own bound (the marker: the v1 age ceiling, :func:`_identity_live`). ``recorded_ct`` is a float or the marker spelling ``"ct:"``. """ try: pid = int(pid) except (TypeError, ValueError): return False return _incarnation(pid, _as_ct(recorded_ct), _real_world()) @dataclass(frozen=True) class _World: """What a marker verdict depends on besides the bytes: us, the clock, the process table. Production uses :func:`_real_world`; the shared corpus injects a table.""" pid: int ct: float | None now: float alive: object # Callable[[int], bool] ct_of: object # Callable[[int], float | None] def _real_world() -> _World: return _World(os.getpid(), _own_create_time(), time.time(), _pid_alive, process_create_time) def _incarnation(pid: int, recorded: float | None, w: _World) -> bool | None: """:func:`incarnation_live` against world ``w``.""" if pid <= 0: return False if pid == w.pid: # we are alive by definition: only the incarnation is in question if w.ct is None: return recorded is None if recorded is None: return False if abs(w.ct - recorded) <= _OWN_CREATE_TIME_EPSILON: return True return recorded.is_integer() and 0 <= w.ct - recorded < _WHOLE_SECOND_CT if not w.alive(pid): return False actual = None if recorded is None else w.ct_of(pid) return None if actual is None else abs(actual - recorded) <= CREATE_TIME_TOLERANCE_SECONDS def _identity_live(pid: int, create_time: float | None, age: float, world: _World | None = None) -> bool: """C1 rule 3 + A1 + A7 rule 4 for one (pid, ct) identity of a marker ``age`` seconds old: :func:`incarnation_live`, where an unprovable identity (a v1 marker, or a creation time we cannot read) may be a reused pid, so only the legacy age ceiling bounds it.""" verdict = _incarnation(pid, create_time, world or _real_world()) return age <= UPDATE_MARKER_MAX_AGE_SECONDS if verdict is None else verdict def _identity_line(pid: int | None = None) -> str: ct = process_create_time(pid) return "" if ct is None else f"ct:{ct:.3f}" # --- ancestry ---------------------------------------------------------------------------- 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 _windows_parent_pid(pid: int) -> int | None: """The parent of ``pid`` from a Toolhelp32 process snapshot (stdlib ctypes). Windows keeps a dead parent's pid in the snapshot and reuses pids, so, like psutil, a "parent" created after the child is a recycled pid, not our parent. """ import ctypes from ctypes import wintypes class PROCESSENTRY32W(ctypes.Structure): _fields_ = [ ("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD), ("th32ProcessID", wintypes.DWORD), ("th32DefaultHeapID", ctypes.c_size_t), ("th32ModuleID", wintypes.DWORD), ("cntThreads", wintypes.DWORD), ("th32ParentProcessID", wintypes.DWORD), ("pcPriClassBase", ctypes.c_long), ("dwFlags", wintypes.DWORD), ("szExeFile", ctypes.c_wchar * 260), ] kernel32 = _windows_kernel32() kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE for walk in (kernel32.Process32FirstW, kernel32.Process32NextW): walk.argtypes = [wintypes.HANDLE, ctypes.POINTER(PROCESSENTRY32W)] walk.restype = wintypes.BOOL snapshot = kernel32.CreateToolhelp32Snapshot(0x2, 0) # TH32CS_SNAPPROCESS if not snapshot or snapshot == ctypes.c_void_p(-1).value: return None parent = None try: entry = PROCESSENTRY32W() entry.dwSize = ctypes.sizeof(PROCESSENTRY32W) found = kernel32.Process32FirstW(snapshot, ctypes.byref(entry)) while found: if entry.th32ProcessID == pid: parent = int(entry.th32ParentProcessID) break found = kernel32.Process32NextW(snapshot, ctypes.byref(entry)) finally: kernel32.CloseHandle(snapshot) if not parent: return None parent_created, child_created = _windows_create_filetime(parent), _windows_create_filetime(pid) if parent_created is not None and child_created is not None and parent_created > child_created: return None return parent def _stdlib_parent_pid(pid: int) -> int | None: """The parent of ``pid`` without psutil, or ``None`` when unresolvable. The update-takeover child is spawned ``-I -S -B`` (hermes_cli/_old_updater.py) so psutil cannot import there — and that grandchild is exactly the process that most needs the two-hop ancestry walk to adopt the orchestrator's marker. /proc serves Linux; macOS keeps /proc absent, so shell out to ps once per hop; Windows has neither, so ask the Toolhelp32 snapshot. """ if sys.platform == "win32": try: return _windows_parent_pid(pid) except (OSError, AttributeError, ValueError): return None try: if os.path.isdir("/proc"): with open(f"/proc/{pid}/stat", "rb") as fh: stat = fh.read() else: out = subprocess.run( ["ps", "-o", "ppid=", "-p", str(pid)], capture_output=True, text=True, encoding="utf-8", errors="replace", check=True, timeout=5, stdin=subprocess.DEVNULL, ).stdout value = int(out.strip() or -1) return value if value > 0 else None except (OSError, ValueError, subprocess.SubprocessError): return None # Field 4 (1-indexed) is ppid, but comm may contain spaces/parens: split # after the closing paren of comm instead of on whitespace. try: return int(stat[stat.rindex(b")") + 2:].split()[1]) except (ValueError, IndexError): return 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 one of our ancestors can only be the claim we are already running under — an unrelated concurrent updater is never in our parent chain. This heals the fleet of staged ``hermes-setup`` binaries that predate the HANDOFF_PID_ENV export and can never send it. The chain is walked one link at a time and each ancestor is tested as it is discovered. ``psutil.Process.parents()`` cannot be used here: it builds the whole chain up to the lowest pid *before* returning, and its per-link ``parent()`` tolerates only ``NoSuchProcess``. So any process we may not inspect anywhere above us raises ``AccessDenied`` and discards the ancestors already collected — including the orchestrator one link down. That is not exotic: under firejail with ``ptrace_scope=1``, and in hardened containers, ``/proc/1`` is unreadable, so the GUI update deadlocked against its own parent on every attempt. Walking incrementally means a failure *above* the match can no longer hide it. Never includes our own pid, and any failure encountered before a match counts as "not an ancestor": an unprovable ancestry must fall back to the normal refusal. """ if pid <= 0: return False if pid == os.getppid(): return True try: import psutil proc = psutil.Process() seen = {proc.pid} for _ in range(_MAX_ANCESTRY_DEPTH): parent = proc.parent() if parent is None: return False if parent.pid == pid: return True if parent.pid in seen: # Defensive only: psutil's create_time check already rejects a # reused ppid, so a true cycle should be unreachable. return False seen.add(parent.pid) proc = parent logger.debug( "Gave up walking process ancestry for pid %s after %s links", pid, _MAX_ANCESTRY_DEPTH, ) return False except ImportError: # -I -S -B takeover child: walk the same chain with stdlib probes. child = os.getpid() for _ in range(32): parent = _stdlib_parent_pid(child) if parent is None: return False if parent == pid: return True if parent == child: # pid 1 re-parenting or a kernel loop guard return False child = parent return False except Exception as exc: logger.debug("Could not walk process ancestry for pid %s: %s", pid, exc) return False def _is_runtime_host(cmdline: list[str]) -> bool: """A long-lived Hermes host (``gateway run`` / ``serve`` / ``dashboard``), by the canonical command-line matchers (profile flags, ``hermes_cli/main.py`` paths, inline bootstraps).""" from gateway.status import looks_like_gateway_command_line from hermes_cli.update_cmd_windows import _hermes_holder_subcommand line = " ".join(cmdline) return looks_like_gateway_command_line(line) or _hermes_holder_subcommand(line) in ("serve", "dashboard") def _runtime_host_below(holder_pid: int) -> bool: """True when a Hermes gateway/serve/dashboard sits between us and *holder_pid* (or anywhere above us when the holder is not reached). Such a host is relaunched BY an update and outlives its stages; a ``hermes update`` its agent or ``/update`` starts is an independent update that must not run under the first one's claim (cli §7 V9). Unreadable command lines count as not-a-host (the legacy adoption stands). """ try: import psutil except ImportError: return False try: proc = psutil.Process().parent() for _ in range(_MAX_ANCESTRY_DEPTH): if proc is None or proc.pid == holder_pid: return False with suppress(psutil.Error): if _is_runtime_host(proc.cmdline()): return True proc = proc.parent() except psutil.Error: return False return False # --- the marker -------------------------------------------------------------------------- @dataclass(frozen=True) class UpdateHolder: """A confirmed-live update holding the lock, or the reason a claim was refused. ``held``: the marker's identities are dead but the checkout kernel lock is still held (a killed updater's completion/build child or git still runs) — the update is NOT over; the hand-off scripts' ``held`` verdict.""" pid: int age_seconds: float reason: str | None = None held: bool = False _U32_MAX = 0xFFFFFFFF _U64_MAX = 0xFFFFFFFFFFFFFFFF _INT_LINE = re.compile(r"[0-9]+", re.ASCII) _CT_VALUE = re.compile(r"[0-9]+(?:\.[0-9]+)?", re.ASCII) _CT_LINE = re.compile(r"ct:([0-9]+(?:\.[0-9]+)?)", re.ASCII) _DELEGATE_LINE = re.compile(r"delegate:([0-9]+) ct:([0-9]+(?:\.[0-9]+)?)", re.ASCII) _RUN_LINE = re.compile(r"run:([A-Za-z0-9._-]{1,128})", re.ASCII) @dataclass(frozen=True) class _Marker: raw: bytes pid: int started_at: int | None create_time: float | None delegate_pid: int | None delegate_create_time: float | None in_flight: bool = False # an empty marker younger than EMPTY_MARKER_GRACE_SECONDS ct_text: str | None = None delegate_ct_text: str | None = None runs: tuple[str, ...] = () @property def base(self) -> bytes: """Lines 1–3 exactly as written (what a delegate keeps byte-identical).""" return b"".join(self.raw.splitlines(keepends=True)[:3]) @property def run(self) -> str | None: return self.runs[0] if self.runs else None def age(self, world: _World | None = None) -> float: now = time.time() if world is None else world.now return now - self.started_at if self.started_at is not None else float("inf") def owner_live(self, world: _World | None = None) -> bool: return self.started_at is not None \ and _identity_live(self.pid, self.create_time, self.age(world), world) def delegate_live(self, world: _World | None = None) -> bool: return self.delegate_pid is not None and self.started_at is not None \ and _identity_live(self.delegate_pid, self.delegate_create_time, self.age(world), world) def live_pid(self, world: _World | None = None) -> int | None: if self.in_flight: return 0 if self.owner_live(world): return self.pid return self.delegate_pid if self.delegate_live(world) else None def canonical(self, pid: int, ct_text: str | None) -> bytes: """A rewritten claim (A7 rule 5): ``pid`` owns it from ``started_at`` on; run lines kept.""" body = f"{pid}\n{self.started_at}\n" + (f"ct:{ct_text}\n" if ct_text else "") return (body + "".join(f"run:{run}\n" for run in self.runs)).encode() def _bounded_int(text: str, limit: int) -> int | None: """ASCII digits whose value fits ``limit`` (u32 pid, u64 started_at — Rust's ``parse``), else None. Leading zeros are fine; the significant digits are counted BEFORE ``int()``, which refuses past 4300 digits — an oversized field is malformed, never an exception.""" if not _INT_LINE.fullmatch(text): return None significant = text.lstrip("0") or "0" if len(significant) > len(str(limit)): return None value = int(significant) return value if value <= limit else None def _parse_marker(raw: bytes, *, mtime: float | None = None) -> _Marker: """Contract A2 + A7 rule 7, identical in every reader (Rust ``marker.rs``, Electron, the hand-off scripts; ``tests/fixtures/update_marker_corpus.json``): BOM, CRLF and surrounding spaces/tabs tolerated; line 1 pid (u32) and line 2 started_at (u64) are integers or the marker is MALFORMED (dead: ``started_at`` None); a bad line 3 makes it v1; lines 4+ are tagged — the first well-formed ``delegate: ct:`` and every ``run:`` — anything else ignored.""" text = raw.decode("utf-8", errors="replace").removeprefix("\ufeff") lines = [line.removesuffix("\r").strip(" \t") for line in text.split("\n")] lines += [""] * (3 - len(lines)) pid = _bounded_int(lines[0], _U32_MAX) started_at = _bounded_int(lines[1], _U64_MAX) if pid is not None else None pid = -1 if pid is None else pid ct = _CT_LINE.fullmatch(lines[2]) delegates = ((m, _bounded_int(m.group(1), _U32_MAX)) for m in map(_DELEGATE_LINE.fullmatch, lines[3:]) if m) delegate, delegate_pid = next(((m, d) for m, d in delegates if d is not None), (None, None)) runs = tuple(m.group(1) for m in map(_RUN_LINE.fullmatch, lines[3:]) if m) in_flight = not raw and mtime is not None and time.time() - mtime < EMPTY_MARKER_GRACE_SECONDS return _Marker( raw=raw, pid=pid, started_at=started_at, create_time=float(ct.group(1)) if ct else None, delegate_pid=delegate_pid, delegate_create_time=float(delegate.group(2)) if delegate else None, in_flight=in_flight, ct_text=ct.group(1) if ct else None, delegate_ct_text=delegate.group(2) if delegate else None, runs=runs, ) def judge_marker(raw: bytes, world: _World | None = None) -> tuple[str, int | None, str | None]: """``(verdict, owner, run)`` of marker bytes — the shared corpus' judge contract. verdict: ``malformed`` | ``dead`` | ``ours`` (a live identity is this incarnation) | ``live``; owner: the owner identity if live, else the delegate if live, else None. """ w = world or _real_world() marker = _parse_marker(raw) if marker.started_at is None: return "malformed", None, None owner = marker.live_pid(w) if owner is None: return "dead", None, marker.run ours = (marker.pid == w.pid and marker.owner_live(w)) \ or (marker.delegate_pid == w.pid and marker.delegate_live(w)) return ("ours" if ours else "live"), owner, marker.run def _release_decision(raw: bytes, world: _World | None = None) -> tuple[str, bytes | None]: """A7 rule 5: what releasing ``world``'s claim does to marker bytes ``raw``. ``("delete", None)`` | ``("rewrite", new bytes)`` | ``("keep", None)``. The owner (line 1–3 identity is our incarnation) deletes — regardless of delegate lines — unless a live delegate other than us exists: then that delegate becomes the owner. A delegate (us) drops its line while the owner lives, else deletes. Anything not naming us is kept. """ w = world or _real_world() marker = _parse_marker(raw) if marker.started_at is None: return "keep", None if marker.pid == w.pid and marker.owner_live(w): if marker.delegate_pid not in (None, w.pid) and marker.delegate_live(w): return "rewrite", marker.canonical(marker.delegate_pid, marker.delegate_ct_text) return "delete", None if marker.delegate_pid == w.pid and marker.delegate_live(w): if marker.owner_live(w): return "rewrite", marker.canonical(marker.pid, marker.ct_text) return "delete", None return "keep", None def _read_bytes(path: Path) -> bytes | None: try: return path.read_bytes() except OSError: return None def _read_marker(path: Path) -> _Marker | None: raw = _read_bytes(path) if raw is None: return None mtime = None if not raw: with suppress(OSError): mtime = path.stat().st_mtime return _parse_marker(raw, mtime=mtime) def _tmp_sibling(path: Path) -> Path: return path.with_name(f"{path.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp") # Every tmp write of ours publishes or is unlinked well inside this: an older tmp under our own # pid number is a previous holder's (containers reuse pids every boot), never ours in flight. OWN_PID_TMP_STALE_SECONDS = 60.0 def _tmp_writer_gone(owner: int | None, entry: Path) -> bool: if owner is None: return True # ASCII digits past u32: no process has that pid if owner != os.getpid(): return not _pid_alive(owner) return time.time() - entry.stat().st_mtime > OWN_PID_TMP_STALE_SECONDS def _sweep_dead_tmp_siblings(path: Path) -> None: """Reclaim ``.[.].tmp`` files whose writer died between write and publish (contract m10): the pid is the first component after the marker name.""" prefix = f"{path.name}." with suppress(OSError): for entry in path.parent.iterdir(): name = entry.name owner = name[len(prefix):].split(".", 1)[0] if not (name.startswith(prefix) and name.endswith(".tmp") and _INT_LINE.fullmatch(owner)): continue with suppress(OSError): if _tmp_writer_gone(_bounded_int(owner, _U32_MAX), entry): entry.unlink() # --- the marker mutex (A7 rule 1) ------------------------------------------------------------- MUTEX_SUFFIX = ".lock" # Bounded wait for the sidecar: holders never run git/builds/network inside it. MUTEX_WAIT_SECONDS = 10.0 class MarkerBusy(OSError): """The marker mutex stayed held past :data:`MUTEX_WAIT_SECONDS`: report busy.""" _MUTEX_HELD = threading.local() def marker_mutex_path(path: Path) -> Path: """``.lock``: the sidecar every marker MUTATION holds a kernel lock on. Never deleted (deleting it would split the lock across two inodes).""" return path.with_name(path.name + MUTEX_SUFFIX) def _windows_open_exclusive(path: Path): """The Windows sidecar mutex: the file opened with NO sharing (``FileShare.None`` in the PowerShell scripts, ``share_mode(0)`` in marker.rs). ``None`` = someone else has it open.""" import ctypes from ctypes import wintypes kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE] kernel32.CreateFileW.restype = wintypes.HANDLE invalid = ctypes.c_void_p(-1).value for access, disposition in ((0xC0000000, 4), (0x80000000, 3)): # RW OPEN_ALWAYS; R OPEN_EXISTING handle = kernel32.CreateFileW(str(path), access, 0, None, disposition, 0x80, None) if handle and handle != invalid: return handle error = ctypes.get_last_error() if error == 32: # ERROR_SHARING_VIOLATION: held return None if error != 5: # anything but access denied: no read-only retry helps break raise ctypes.WinError(error) def _windows_close(handle) -> None: import ctypes ctypes.WinDLL("kernel32").CloseHandle(handle) def _mutex_acquire(path: Path, wait: float): deadline = time.monotonic() + wait if sys.platform == "win32": while True: handle = _windows_open_exclusive(path) if handle is not None: return handle if time.monotonic() >= deadline: raise MarkerBusy(f"{path} is held by another update") time.sleep(0.02) import fcntl try: fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644) except PermissionError: fd = os.open(path, os.O_RDONLY) # flock works on a read-only fd (root-owned sidecar) try: while True: try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) return fd except BlockingIOError: if time.monotonic() >= deadline: raise MarkerBusy(f"{path} is held by another update") from None time.sleep(0.02) except BaseException: os.close(fd) raise @contextmanager def marker_mutex(path: Path, *, wait: float = MUTEX_WAIT_SECONDS): """Hold the kernel lock on ``.lock`` (A7 rule 1): read → judge → mutate of the marker at ``path`` happens entirely inside. Re-entrant per thread; the kernel releases it if we die. Raises :class:`MarkerBusy` after ``wait`` seconds, ``OSError`` when it cannot be opened.""" key = str(marker_mutex_path(path)) held = getattr(_MUTEX_HELD, "keys", None) if held is None: held = _MUTEX_HELD.keys = {} if held.get(key): held[key] += 1 try: yield finally: held[key] -= 1 return handle = _mutex_acquire(Path(key), wait) held[key] = 1 try: yield finally: held.pop(key, None) if sys.platform == "win32": _windows_close(handle) else: os.close(handle) # closing the only fd of this open file description drops the flock def _compare_and_swap(path: Path, expected: bytes, new: bytes) -> bool: """Atomically replace ``path`` (tmp + ``os.replace``) only while it still holds ``expected``, under the marker mutex.""" with marker_mutex(path): if _read_bytes(path) != expected: return False tmp = _tmp_sibling(path) try: with open(tmp, "wb") as fh: fh.write(new) fh.flush() os.fsync(fh.fileno()) os.replace(tmp, path) return True except OSError as exc: logger.debug("Could not rewrite update marker %s: %s", path, exc) return False finally: with suppress(OSError): tmp.unlink() def _live_partners(marker: _Marker) -> list[int]: """Live identities behind a marker: the owner, then the delegate (``[0]`` for a claim still being written). A claim naming our own pid counts only at our exact incarnation.""" if marker.in_flight: return [0] partners = [] if marker.owner_live(): partners.append(marker.pid) if marker.delegate_live(): partners.append(marker.delegate_pid) return partners def read_live_update(*, path: Path | None = None, install_root: Path | str | None = None) -> UpdateHolder | None: """Return the live update holding the marker, or ``None``. Absent, unreadable, malformed and dead-owner all mean "no live update" — unless the checkout kernel lock of ``install_root`` (default: this checkout) is held: then a killed updater's tree (its completion, build or git) still runs, the marker is kept, and the answer is a ``held`` holder (R6; ``marker.sh``/``marker.ps1`` answer ``held`` too). Otherwise a dead marker is reclaimed — judged again and removed inside the marker mutex, so a claim published after our first read is never the one deleted. Never raises: a marker that cannot be judged at all also reads as ``None``. That fails open on the MARKER only; the checkout kernel lock is the guard, and every caller that acts on ``None`` still consults it (``update_in_progress`` ORs :func:`checkout_lock_held`; ``UpdateLock.acquire`` / ``acquire_checkout`` take it), so a live update is never joined or raced through this answer. """ marker = path or update_marker_path() try: # "live" from the locked recheck = a claim replaced our dead snapshot: judge that claim # rather than answer "clear" (F2) — a marker-only claim has no checkout lease to OR in. for _attempt in range(2): parsed = _read_marker(marker) if parsed is None: return None live = parsed.live_pid() if live is not None: return UpdateHolder(pid=live, age_seconds=parsed.age() if parsed.started_at is not None else 0.0) verdict = _reclaim_dead(marker, install_root) if verdict == "held": return UpdateHolder(pid=0, held=True, age_seconds=max(parsed.age(), 0.0) if parsed.started_at is not None else 0.0) if verdict != "live": break except Exception as exc: # health: allow BLE001 -- never raises: fails open on the marker only; the kernel lock guards (doc) logger.debug("Could not judge update marker %s: %s", marker, exc) return None def _reclaim_dead(path: Path, install_root: Path | str | None = None) -> str: """Remove a dead marker under the mutex: ``reclaimed`` | ``held`` (dead, but the checkout lock is held, so it is kept) | ``live`` (a claim appeared) | ``absent`` | ``busy``.""" try: with marker_mutex(path): current = _read_marker(path) if current is None: return "absent" if current.live_pid() is not None: return "live" if checkout_lock_held(install_root): return "held" with suppress(FileNotFoundError): path.unlink() return "reclaimed" except OSError as exc: # MarkerBusy included: someone else is deciding right now logger.debug("Left update marker %s for its current mutator: %s", path, exc) return "busy" def legacy_profile_claims(root: Path | None = None) -> list[UpdateHolder]: """Live claims in pre-root-marker ``/profiles/

/.hermes-update-in-progress`` files (R11). Before the marker moved to the profile-tree root, an update under a named profile claimed its PROFILE home and never took the checkout lock. During the transition such an update can still be running: honor it (v1 rules: pid alive, within the legacy age ceiling, never our own previous incarnation). Read-only: an old updater reclaims its own dead markers. """ root = update_marker_path().parent if root is None else root claims = [] with suppress(OSError): for profile in (root / "profiles").iterdir(): marker = _read_marker(profile / MARKER_NAME) if marker is None or marker.in_flight: continue live = marker.live_pid() if live: claims.append(UpdateHolder(pid=live, age_seconds=marker.age())) return claims def describe_holder(holder: UpdateHolder | None) -> str: """One-line, user-facing explanation of who holds the update lock.""" if holder is not None and holder.reason: return ( f"✗ Cannot lock this install for the update: {holder.reason}.\n" "\n" " Updating without the lock could let two updates corrupt the install, or run\n" " under a Desktop app, gateway or installer that cannot see it. Make that path\n" " writable (fix its owner or permissions, or remount a read-only filesystem\n" " read-write), or run `hermes update` as the user that owns the install." ) minutes, seconds = divmod(int(max(0 if holder is None else holder.age_seconds, 0)), 60) elapsed = f"{minutes}m {seconds}s" if minutes else f"{seconds}s" who = f", process {holder.pid}" if holder and holder.pid else "" if holder is not None and holder.held: who = "; its owner exited but a process it started still holds the checkout" return ( f"✗ Another Hermes update is already running (started {elapsed} ago{who}).\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." ) # --- the checkout lock ------------------------------------------------------------------- # This process's hold on the checkout lock: {"path", "fd", "owned", "depth"}. "owned" means # we opened and locked it; otherwise the fd was inherited from the `hermes update` that holds # it (pass_fds) and belongs to the whole tree — it is never unlocked or closed here. _HELD: dict | None = None _JOBS: list = [] def _try_lock(fd: int) -> bool: if sys.platform == "win32": if not _lock_bytes(fd, _WINDOWS_LOCK_OFFSET): return False if not _lock_bytes(fd, _WINDOWS_LOCK_OFFSET + 1, _LEASE_SLOTS): # a dead owner's leased child _unlock(fd) return False _unlock(fd, _WINDOWS_LOCK_OFFSET + 1, _LEASE_SLOTS) return True import fcntl try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: return False return True def _lock_bytes(fd: int, offset: int, count: int = 1) -> bool: """Windows: lock ``count`` bytes at ``offset`` without waiting (fails if any is held).""" import msvcrt os.lseek(fd, offset, os.SEEK_SET) try: msvcrt.locking(fd, msvcrt.LK_NBLCK, count) except OSError: return False return True def _unlock(fd: int, offset: int = _WINDOWS_LOCK_OFFSET, count: int = 1) -> None: if sys.platform == "win32": import msvcrt with suppress(OSError): os.lseek(fd, offset, os.SEEK_SET) msvcrt.locking(fd, msvcrt.LK_UNLCK, count) def _take_lease(fd: int) -> int | None: """Windows: lock a free lease byte (R5b) on ``fd``; its offset, or None when all are held (the tree's other joiners hold them: this one still runs inside their custody).""" for offset in range(_WINDOWS_LOCK_OFFSET + 1, _WINDOWS_LOCK_OFFSET + 1 + _LEASE_SLOTS): if _lock_bytes(fd, offset): return offset return None def _inherited_lock_fd(path: Path) -> int | None: """An fd this process inherited that holds the lock on ``path`` (POSIX ``pass_fds``).""" if sys.platform == "win32": return None try: target = os.stat(path) fd_dir = "/proc/self/fd" if os.path.isdir("/proc/self/fd") else "/dev/fd" candidates = [int(name) for name in os.listdir(fd_dir) if name.isdigit()] except OSError: return None import fcntl for fd in candidates: try: # pass_fds clears CLOEXEC in the exec'd child. Local opens (including the # concurrent status probe) retain it and must never donate their lifetime: # their owner may close the fd while our update still holds custody. if not os.get_inheritable(fd): continue st = os.fstat(fd) if (st.st_dev, st.st_ino) != (target.st_dev, target.st_ino): continue # Succeeds only when this open file description already holds the lock (or the # lock is free, in which case taking it on an fd we hold is still correct). fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) return fd except OSError: continue return None def _lock_holder(fd_or_path) -> UpdateHolder: """Who holds the checkout lock, from its holder record. A recorded updater that is gone (its incarnation is dead) while the lock stays held means a process it started holds it: that is the ``held`` holder, never the dead pid (the marker reader's wording, R6).""" raw = _read_bytes(fd_or_path) or b"" parsed = _parse_marker(raw) age = parsed.age() if parsed.started_at else 0.0 if parsed.pid > 0 and incarnation_live(parsed.pid, parsed.create_time) is False: return UpdateHolder(pid=0, age_seconds=age, held=True) return UpdateHolder(pid=max(parsed.pid, 0), age_seconds=age) def _open_lock_file(path: Path) -> tuple[int | None, object]: """``(fd, True)`` read-write; ``(fd, False)`` read-only for an existing lock file we may not write (left root-owned by a ``sudo hermes update``: the kernel lock works on a read-only fd, contract A5; POSIX only — a Windows owner needs its record); ``(None, reason)`` when neither opens.""" # Never follow a link planted at the name: the holder record is written into this file. binary = getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) try: return os.open(path, os.O_RDWR | os.O_CREAT | binary, 0o644), True except PermissionError as exc: denied = exc except OSError as exc: return None, exc.strerror or exc try: return os.open(path, os.O_RDONLY | binary), False except OSError: return None, denied.strerror or denied def _acquire_checkout(install_root: Path) -> UpdateHolder | None: """Take (or join, when inherited) the checkout lock; the refusal holder, else ``None``.""" global _HELD path = checkout_lock_path(install_root) if _HELD is not None and _HELD["path"] == str(path): _HELD["depth"] += 1 return None inherited = _inherited_lock_fd(path) if inherited is not None: _HELD = {"path": str(path), "fd": inherited, "owned": False, "depth": 1} return None fd, writable = _open_lock_file(path) if fd is None: return UpdateHolder(pid=0, age_seconds=0.0, reason=f"{path} is not writable ({writable}); the update " "locks the checkout there, in its git directory, which git must write too") found = os.fstat(fd) if not S_ISREG(found.st_mode) or found.st_nlink != 1: # A hard link (or special file) at the name: truncating it would rewrite data outside # the install. Refuse; never unlink/recreate it (waiters need one stable inode). os.close(fd) return UpdateHolder(pid=0, age_seconds=0.0, reason=f"{path} is not a regular single-link file; delete it") try: if not _lock_with_contention_wait(fd, path): if _held_by_our_windows_ancestor(path): # Windows has no fd inheritance: the update tree's children run in the lock # owner's kill-on-close job (bind_child_to_update_tree) and die with it. One the # job refused does not, so every joiner also holds a lease byte (R5b), taken # while the owner is seen alive: it never covers a writer after a free lock. lease = _take_lease(fd) if _held_by_our_windows_ancestor(path): _HELD = {"path": str(path), "fd": None, "owned": False, "depth": 1, "lease": None if lease is None else (fd, lease)} if lease is None: os.close(fd) return None if lease is not None: _unlock(fd, lease) os.close(fd) return _lock_holder(path) if writable is not True and sys.platform == "win32": # No fd inheritance on Windows: the update tree's children find their owner by # this record (_held_by_our_windows_ancestor), so an owner that cannot write it # would admit itself and then have its own children refused. _unlock(fd) os.close(fd) return UpdateHolder(pid=0, age_seconds=0.0, reason=f"{path} is read-only; delete it or fix its permissions") if writable is True: record = f"{os.getpid()}\n{int(time.time())}\n{_identity_line()}\n".encode() os.lseek(fd, 0, os.SEEK_SET) os.ftruncate(fd, 0) os.write(fd, record) except OSError as exc: _unlock(fd) os.close(fd) return UpdateHolder(pid=0, age_seconds=0.0, reason=f"{path} could not be locked ({exc})") _HELD = {"path": str(path), "fd": fd, "owned": True, "depth": 1} return None # A "held?" probe (marker.sh/marker.ps1 checkout_lock_held, Python checkout_lock_held) takes the # lock for microseconds: an updater that collides with one waits this long before "busy" (D17: # 257 of 3283 tight-loop acquires failed spuriously with no real holder). CHECKOUT_CONTENTION_WAIT_SECONDS = 3.0 def _lock_with_contention_wait(fd: int, path: Path) -> bool: if _try_lock(fd): return True if _held_by_our_windows_ancestor(path): return False # our own update tree holds it and never frees it for us: answer at once deadline = time.monotonic() + CHECKOUT_CONTENTION_WAIT_SECONDS while time.monotonic() < deadline: time.sleep(0.02) if _try_lock(fd): return True return False def _held_by_our_windows_ancestor(path: Path) -> bool: """Windows: the checkout lock's holder record names a LIVE incarnation of one of our ancestors (the ``hermes update`` whose completion/build children we are).""" if sys.platform != "win32": return False record = _parse_marker(_read_bytes(path) or b"") if record.started_at is None or record.pid == os.getpid(): return False return incarnation_live(record.pid, record.create_time) is True and _is_ancestor_pid(record.pid) def _release_checkout() -> None: global _HELD if _HELD is None: return _HELD["depth"] -= 1 if _HELD["depth"] > 0: return held, _HELD = _HELD, None if held.get("lease"): lease_fd, offset = held["lease"] _unlock(lease_fd, offset) with suppress(OSError): os.close(lease_fd) if held["owned"]: # Close, never LOCK_UN: flock belongs to the open file description, which completion # children share through pass_fds. A survivor keeps the checkout locked until it exits. _unlock(held["fd"]) with suppress(OSError): os.close(held["fd"]) def checkout_lock_fds(install_root: Path | str | None = None) -> tuple[int, ...]: """Fds a child of the update tree must inherit (``subprocess`` ``pass_fds``) so the checkout stays locked while it runs, even after its parent is killed. POSIX only.""" if sys.platform == "win32": return () if _HELD is not None: return () if _HELD["fd"] is None else (_HELD["fd"],) fd = _inherited_lock_fd(checkout_lock_path(install_root)) return () if fd is None else (fd,) def custody_spawn_kwargs() -> dict: """``subprocess`` kwargs that keep a checkout MUTATOR (git, the Node source build) in the update tree's custody (R2): POSIX children inherit the held lock fd, so the checkout stays locked until the last of them exits even if this process is killed. ``{}`` when this process holds no checkout lock, and on Windows (bind the Popen with bind_child_to_update_tree).""" if sys.platform == "win32" or _HELD is None or _HELD["fd"] is None: return {} return {"pass_fds": (_HELD["fd"],)} CREATE_SUSPENDED = 0x00000004 def bind_child_to_update_tree(proc: subprocess.Popen) -> OSError | None: """Windows: put an update-tree child in a kill-on-close job owned by this process, so the child (and everything it spawns) dies when the lock owner dies and frees the lock. Create ``proc`` with :data:`CREATE_SUSPENDED` and resume it after the bind (:func:`resume_suspended_child`): it runs no instruction before, so nothing it starts can escape the job. The job allows breakaway: a process the tree starts with ``CREATE_BREAKAWAY_FROM_JOB`` (the gateways an update restarts or resumes, ``gateway_windows._spawn_detached``) leaves it and outlives the update; every other descendant stays bound. Not ``SILENT_BREAKAWAY_OK``, which would let every descendant escape. POSIX children inherit the lock fd instead (:func:`checkout_lock_fds`). Returns ``None`` when bound, else the refusal (logged): the caller runs post-commit work, which must not fail over a weaker lock, so it records the refusal and runs the child, which joins the lock holding its own lease byte (R5b): the checkout stays busy until it exits, even after the owner's death. """ if sys.platform != "win32": return None try: _bind_to_kill_on_close_job(proc) except OSError as exc: logger.warning("Could not bind update child %s to the update's job: %s", proc.pid, exc) return exc return None def resume_suspended_child(proc: subprocess.Popen) -> None: """Windows: resume a :data:`CREATE_SUSPENDED` child (a no-op for a running one). A failed resume kills it and raises ``OSError``.""" import ctypes ntdll = ctypes.WinDLL("ntdll") ntdll.NtResumeProcess.argtypes = [ctypes.c_void_p] ntdll.NtResumeProcess.restype = ctypes.c_long if ntdll.NtResumeProcess(int(proc._handle)) != 0: proc.kill() raise OSError(f"could not resume update child {proc.pid}") def _bind_to_kill_on_close_job(proc: subprocess.Popen) -> None: import ctypes from ctypes import wintypes kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] kernel32.AssignProcessToJobObject.restype = wintypes.BOOL if not kernel32.AssignProcessToJobObject(update_tree_job(), int(proc._handle)): raise ctypes.WinError(ctypes.get_last_error()) def update_tree_job() -> int: """Windows: this process's kill-on-close job for the update tree (created once, never closed: the handle closes when this process dies, killing every process still in it).""" if _JOBS: return _JOBS[0] import ctypes from ctypes import wintypes class _Basic(ctypes.Structure): _fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64), ("PerJobUserTimeLimit", ctypes.c_int64), ("LimitFlags", wintypes.DWORD), ("MinimumWorkingSetSize", ctypes.c_size_t), ("MaximumWorkingSetSize", ctypes.c_size_t), ("ActiveProcessLimit", wintypes.DWORD), ("Affinity", ctypes.c_size_t), ("PriorityClass", wintypes.DWORD), ("SchedulingClass", wintypes.DWORD)] class _Extended(ctypes.Structure): _fields_ = [("BasicLimitInformation", _Basic), ("IoInfo", ctypes.c_ulonglong * 6), ("ProcessMemoryLimit", ctypes.c_size_t), ("JobMemoryLimit", ctypes.c_size_t), ("PeakProcessMemoryUsed", ctypes.c_size_t), ("PeakJobMemoryUsed", ctypes.c_size_t)] kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] kernel32.CreateJobObjectW.restype = wintypes.HANDLE kernel32.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD] kernel32.SetInformationJobObject.restype = wintypes.BOOL kernel32.CloseHandle.argtypes = [wintypes.HANDLE] job = kernel32.CreateJobObjectW(None, None) # unnamed, non-inheritable: only we hold it if not job: raise ctypes.WinError(ctypes.get_last_error()) limits = _Extended() # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK limits.BasicLimitInformation.LimitFlags = 0x2000 | 0x0800 if not kernel32.SetInformationJobObject(job, 9, ctypes.byref(limits), ctypes.sizeof(limits)): error = ctypes.get_last_error() kernel32.CloseHandle(job) raise ctypes.WinError(error) _JOBS.append(job) # never closed: the handle closes when this process dies, killing the tree return job def holds_checkout_lock(install_root: Path | str | None = None) -> bool: """True when this process holds (or joined) the checkout lock: it IS the running update.""" return _HELD is not None and os.path.realpath(_HELD["path"]) == os.path.realpath(checkout_lock_path(install_root)) def update_in_progress(install_root: Path | str | None = None) -> bool: """True while an update owns this install: a LIVE marker or a held checkout lock.""" return read_live_update(install_root=install_root) is not None or checkout_lock_held(install_root) def checkout_lock_held(install_root: Path | str | None = None) -> bool: """True while some process (this one included) holds the checkout kernel lock. A probe takes the lock for the microseconds of one try and drops it (closing the fd), the way ``marker.sh::checkout_lock_held`` does; an updater acquiring at that instant waits :data:`CHECKOUT_CONTENTION_WAIT_SECONDS` instead of failing (R6/D17). Only a missing lock file is free. One that exists but cannot be opened answers held, the same verdict ``marker.sh`` / ``marker.ps1`` and the Desktop probes give, so no reader admits a launch the others would park. A lock call that fails outright (ENOLCK on NFS without lockd, EOPNOTSUPP on some SMB shares) means nothing can hold it: free, so the interrupted-pull repair runs unguarded there as designed.""" if holds_checkout_lock(install_root): return True path = checkout_lock_path(install_root) try: fd = os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0)) except FileNotFoundError: return False except OSError: return True try: if not _try_lock(fd): return True _unlock(fd) return False except OSError: return False finally: os.close(fd) # --- the lock object --------------------------------------------------------------------- def _publish_exclusive(path: Path, body: bytes) -> bool: """Contract A3: publish ``body`` at ``path`` only if nothing is there, never as an empty file a reader could judge dead. Write a private tmp sibling, then hard-link it into place (fails if the marker exists); a filesystem without hard links falls back to an exclusive create (readers grant a young empty marker EMPTY_MARKER_GRACE_SECONDS). False = taken.""" tmp = _tmp_sibling(path) try: with open(tmp, "xb") as fh: fh.write(body) fh.flush() os.fsync(fh.fileno()) try: os.link(tmp, path) return True except FileExistsError: return False except OSError as exc: logger.debug("No hard link for the update marker (%s); exclusive create instead", exc) finally: with suppress(OSError): tmp.unlink() try: fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o644) except FileExistsError: return False made = os.fstat(fd) try: if os.write(fd, body) != len(body): raise OSError(errno.ENOSPC, f"short write publishing {path}") os.fsync(fd) except BaseException: # A torn claim names our live pid, so it would block every updater for as long as we # run without our ever having acquired: withdraw the inode WE created — compared under # the marker mutex, so a claimant that reclaimed it and published its own keeps that. os.close(fd) with suppress(OSError), marker_mutex(path): now = os.stat(path) if (now.st_dev, now.st_ino) == (made.st_dev, made.st_ino): path.unlink() raise os.close(fd) return True def _marker_unwritable(path: Path, exc: OSError) -> str: """Why an unwritable marker location refuses the update, not just that it is unwritable.""" return (f"{path} is not writable ({exc}); the update records itself there so the Desktop app, " "gateways, installers and other updaters wait for it") # Per-process claim depth for each marker path: nested UpdateLocks (an update's completion run # in-process, venv_sync finishing a tail) share one claim, released by the outermost. _CLAIMS: dict[str, int] = {} class UpdateLock: """Context manager owning the shared update marker (and, with ``install_root``, the checkout lock) for this process. ``acquired`` is True when we wrote the marker; adopting a live partner's claim (hand-off pid, ancestor, or an outer claim of this same process) succeeds with ``acquired`` False. ``acquire`` returns False (and sets ``holder``) when another live update owns either lock or the lock cannot be created at all — never "proceed unlocked". ``install_root`` names the checkout whose kernel lock guards the claim (default: this checkout). With ``checkout_first`` (the default, when ``install_root`` is given) ``acquire`` takes that lock before the marker. ``checkout_first=False`` claims the marker alone — a launch that may run under a live update's claim (venv_sync) and takes the checkout lock later, only for its own mutation. Either way a dead marker over a checkout lock held by another process tree is refused as ``held`` and kept, never reclaimed (R6). """ def __init__(self, *, path: Path | None = None, install_root: Path | str | None = None, checkout_first: bool = True) -> None: self.path = path or update_marker_path() self.install_root = None if install_root is None else Path(install_root) self.checkout_first = checkout_first self.acquired = False self.holder: UpdateHolder | None = None self._claimed = False self._checkout = False def acquire(self) -> bool: if self.install_root is not None and self.checkout_first \ and not self.acquire_checkout(self.install_root): return False try: ok = self._claim_marker() except BaseException: self._drop_checkout() raise if not ok: self._drop_checkout() return False key = str(self.path) _CLAIMS[key] = _CLAIMS.get(key, 0) + 1 self._claimed = True return True def acquire_checkout(self, install_root: Path | str) -> bool: """Take (or join) the checkout lock — ACQUIRE custody, never sample it (R2): a caller that will mutate the checkout under a marker it adopted or claimed calls this first.""" if self._checkout: return True refused = _acquire_checkout(Path(install_root)) if refused is not None: self.holder = refused return False self._checkout = True return True def _claim_marker(self) -> bool: try: self.path.parent.mkdir(parents=True, exist_ok=True) except OSError as exc: self.holder = UpdateHolder(pid=0, age_seconds=0.0, reason=_marker_unwritable(self.path.parent, exc)) return False _sweep_dead_tmp_siblings(self.path) if _CLAIMS.get(str(self.path)) is None: legacy = [claim for claim in legacy_profile_claims(self.path.parent) if not self._is_partner(claim.pid)] if legacy: self.holder = legacy[0] return False body = f"{os.getpid()}\n{int(time.time())}\n{_identity_line()}\n".encode() try: for _attempt in range(3): # A7 rule 2: an exclusive create needs no mutex — it only succeeds on no marker. if _publish_exclusive(self.path, body): self.acquired = True return True with marker_mutex(self.path): existing = _read_marker(self.path) if existing is None: continue # vanished between publish and read: retry if _live_partners(existing): return self._adopt_or_refuse(existing) if self._checkout_held_elsewhere(): # R6: the marker's owner is dead but a process it started (completion, # build, git) still holds the checkout. The update is not over: keep # its marker and refuse as ``held``. self.holder = UpdateHolder( pid=0, held=True, age_seconds=max(existing.age(), 0.0) if existing.started_at is not None else 0.0) return False # Dead or malformed, judged under the mutex: reclaim and claim in one hold. with suppress(FileNotFoundError): self.path.unlink() if _publish_exclusive(self.path, body): self.acquired = True return True except MarkerBusy: self.holder = UpdateHolder(pid=0, age_seconds=0.0) return False except OSError as exc: self.holder = UpdateHolder(pid=0, age_seconds=0.0, reason=_marker_unwritable(self.path, exc)) return False self.holder = read_live_update(path=self.path, install_root=self.install_root) \ or UpdateHolder(pid=0, age_seconds=0.0) return False def _checkout_held_elsewhere(self) -> bool: """The checkout lock is held, and not by this process's own update tree (our hold, or a lock fd inherited from the update that spawned us — that tree may reclaim).""" if self._checkout: return False path = checkout_lock_path(self.install_root) if _HELD is not None and _HELD["path"] == str(path): return False # Only when held: a free lock is not taken by the inherited-fd lookup below. return checkout_lock_held(self.install_root) and _inherited_lock_fd(path) is None @staticmethod def _is_partner(pid: int) -> bool: return pid == os.getpid() or (pid and (pid == _handoff_pid() or _is_ancestor_pid(pid))) def _adopt_or_refuse(self, existing: _Marker) -> bool: """C1 rule 4: a LIVE claim by us, an ancestor or the hand-off partner is run under. Called inside the marker mutex.""" partners = _live_partners(existing) if not any(self._is_partner(p) and (p == os.getpid() or not _runtime_host_below(p)) for p in partners): self.holder = UpdateHolder(pid=partners[0], age_seconds=existing.age() if existing.started_at else 0.0) return False own = _identity_line() if os.getpid() not in partners and existing.delegate_pid not in partners \ and existing.create_time is not None and own: # Rule 6: name ourselves as the delegate (line 4) so the claim stays visible if the # partner (a hand-off script, the Tauri updater) dies while this update still runs. base = existing.base if existing.base.endswith(b"\n") else existing.base + b"\n" delegated = base + f"delegate:{os.getpid()} {own}\n".encode() \ + "".join(f"run:{run}\n" for run in existing.runs).encode() _compare_and_swap(self.path, existing.raw, delegated) return True def _drop_checkout(self) -> None: if self._checkout: self._checkout = False _release_checkout() def release(self) -> None: """Give back our part of the claim (A7 rule 5), decided under the marker mutex by identity, not by the bytes we once wrote: whatever names this incarnation — as owner, or as a delegate someone else wrote in (the hand-off script names its child) — is released; a live delegate inherits an owner's claim. Only the outermost claim of a process releases. Never raises.""" try: if self._claimed: key = str(self.path) _CLAIMS[key] = _CLAIMS.get(key, 1) - 1 if _CLAIMS[key] <= 0: _CLAIMS.pop(key, None) _release_marker(self.path) except OSError as exc: logger.debug("Could not release update marker %s: %s", self.path, exc) finally: self.acquired = self._claimed = False self._drop_checkout() def __enter__(self) -> UpdateLock: self.acquire() return self def __exit__(self, *_exc) -> None: self.release() def _release_marker(path: Path) -> None: with marker_mutex(path): raw = _read_bytes(path) if raw is None: return action, new = _release_decision(raw) if action == "delete": with suppress(FileNotFoundError): path.unlink() elif action == "rewrite": _compare_and_swap(path, raw, new)