1598 lines
69 KiB
Python
1598 lines
69 KiB
Python
"""Cross-process mutual exclusion for in-flight Hermes updates.
|
||
|
||
Two artifacts, one authority each:
|
||
|
||
* The update marker ``<root hermes home>/.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::
|
||
|
||
<pid>\\n<started_at>\\nct:<owner creation time, 3 decimals>\\n[delegate:<pid> ct:<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 ``<git common dir>/hermes-update.lock`` (``<install root>/.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 ``<root>/profiles/<p>``; 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: <path>`` 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:<s>"`` / ``"<s>"`` 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:<seconds>"``.
|
||
"""
|
||
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:<pid> ct:<ct>`` and every ``run:<id>`` — 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 ``<marker>.<pid>[.<token>].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:
|
||
"""``<marker>.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 ``<marker>.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 ``<root>/profiles/<p>/.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)
|