Files
hermes-agent/hermes_cli/update_custody.py
2026-10-10 15:20:13 +02:00

815 lines
39 KiB
Python

"""Checkout custody for the updater's children (R2): the ONE spawn policy for every git (and the
Node build) an update starts.
The checkout kernel lock (``update_lock``) must stay held while any process of the update tree
can still write the checkout, and must NOT leak into processes that outlive the update:
* Every updater git call carries ``-c gc.autoDetach=false -c maintenance.auto=false``: git never
forks a detached gc/maintenance child that would inherit (POSIX) or outlive (Windows) the lock.
* POSIX: the lock fd is inherited ONLY by git commands that mutate the worktree, index or refs
locally (:data:`LOCAL_MUTATORS`, run with ``core.fsmonitor=false`` so no fsmonitor daemon
starts under them, with no credential helper and no repository hooks). Network/credential commands (fetch,
ls-remote, credential) and readers run without it: a ``git credential-cache--daemon`` they
start never holds the checkout. A partial clone's mutator would lazily fetch the objects a move
needs as a child holding the fd (and start the daemon under it), so before a move the objects
are fetched without the fd and with the user's helpers (:func:`_prefetch_for_move`); a lazy
fetch the prefetch missed still runs, just without a helper. A killed fetch that leaves
``*.lock`` ref files is recovered by the stale-lock rules (``gitlock.clear_stale_git_locks``).
* Windows has no fd inheritance: while this process holds (or joined) the checkout lock, every
child started here is created SUSPENDED, assigned to the update's kill-on-close job and only
then resumed, so it and everything it spawns die with the lock owner. The Node build goes
through ``pm.progress.run_contained`` (whose Popen it never sees): :func:`contained_command`
wraps it in a stdlib launcher that joins the job before it starts node and, once node exits,
terminates and waits out everything node left running (C3): a normal release never frees the
checkout under a build descendant.
* Windows fails CLOSED (D2): a child the job refuses is never run. The msvcrt byte lock belongs
to the process that took it — an inherited handle does not keep it, the kernel unlocks it when
the owner exits — so nothing else could fence a writer outside the job: after the owner's
death the lock would be free while it still wrote. A refused bind kills the still-suspended
child; a refused launcher join exits before it starts node; either raises
:class:`CustodyRefused`, a clear refusal of that step instead of an unfenced writer.
Every updater git runner (``update_cmd._git_run``, ``update_cmd_git._git_run``,
``update_cmd_stash``, ``update_cmd_check``, ``gitlock``, ``update_cmd_commit``,
``_early_recovery``'s restore) calls :func:`run_git`.
Not covered, on purpose: the read-only release/check readers (``source_releases``,
``source_check``: ``ls-remote``, ``cherry``, ``rev-parse``) run their own probes; they never get
the lock fd and write nothing, so they cannot leak or outlive custody of the checkout.
"""
from __future__ import annotations
import contextlib
import logging
import os
import subprocess
import sys
from collections.abc import Sequence
import itertools
logger = logging.getLogger(__name__)
# No detached child: `git gc --auto` / `maintenance run --auto --detach` would otherwise fork a
# daemonized repack after any command that writes objects (commit, merge, fetch, stash).
GIT_NO_DETACH = ("-c", "gc.autoDetach=false", "-c", "maintenance.auto=false")
# Local mutators only (they hold the lock fd): never start an fsmonitor daemon under it, and no
# credential helper — a promisor lazy fetch under a mutator would start `git
# credential-cache--daemon` holding the fd for its lifetime (900 s by default, m3). No repository
# hooks either (F1): a hook inherits the fd, and one that backgrounds a process (a post-merge
# daemon) kept a completed update's checkout locked until it exited. Per command only: the
# repository's own hook configuration is untouched.
_MUTATOR_CONFIG = ("-c", "core.fsmonitor=false", "-c", "credential.helper=", "-c", f"core.hooksPath={os.devnull}")
# git subcommands that write the worktree, the index or refs on THIS machine. Only these inherit
# the checkout lock fd: if the updater dies mid-command, the checkout stays locked until git exits.
# No `pull` (fetch + merge: its network half must not hold the fd; the updater fetches, then
# merges). `gc` packs refs (and its repack writes the object store); `pack-objects` is the pack
# tidy's merge, whose output the update moves into the object store.
LOCAL_MUTATORS = frozenset({
"add", "am", "apply", "checkout", "checkout-index", "cherry-pick", "clean", "commit", "gc",
"merge", "mv", "pack-objects", "read-tree", "rebase", "reset", "restore", "revert", "rm", "stash",
"switch", "update-index", "update-ref", "symbolic-ref", "tag", "branch", "worktree",
})
# Mutators that move the checkout to another commit: what they read from a promisor remote is
# fetched first, without the fd (_prefetch_for_move).
_MOVES = frozenset({"checkout", "merge", "read-tree", "reset", "switch"})
# Global options before the subcommand that take a separate value argument.
_GLOBAL_WITH_VALUE = frozenset({"-c", "-C", "--git-dir", "--work-tree", "--namespace", "--exec-path",
"--config-env", "--super-prefix", "--attr-source", "--list-cmds"})
_CREATE_SUSPENDED = 0x00000004
class CustodyRefused(OSError):
"""Windows would not put an update child in the update's kill-on-close job, so the child was
never run (D2): outside the job it could outlive a killed update and write the checkout after
the checkout lock is free."""
def __init__(self, argv: Sequence[str], cause: object) -> None:
import os
name = os.path.basename(str(argv[0])) if argv else "child"
self.reason = (
f"Windows would not put `{name}` in this update's process job ({cause}), so it was not "
"run: a checkout writer outside the job could outlive a killed update and write the "
"checkout after its lock is released.")
super().__init__(f"{self.reason} {_RETRY.format(command='the command')}")
_RETRY = ("Run {command} again from a regular terminal, outside any sandbox or tool that confines "
"the processes it starts.")
# This run's custody (m2): whether an update child already ran in custody, and the first refusal.
# Many readers swallow an OSError (a reader's None, a best-effort probe), so a refusal can end
# the update as a misleading downstream error; the command's failure path prints
# :func:`refusal_notice` instead.
_RUN: dict = {"ran": False, "refused": None}
def _refuse(argv: Sequence[str], cause: object, *, receipted: bool = False) -> CustodyRefused:
"""The :class:`CustodyRefused` for ``argv``: logged, noted in the receipt (unless the caller
already did) and kept as this run's first refusal."""
argv = list(argv) if isinstance(argv, (list, tuple)) else [argv]
exc = CustodyRefused(argv, cause)
detail = f"{exc.reason}: {' '.join(str(arg) for arg in argv[:2])}"
if not receipted:
logger.warning("Refused update child: %s", detail)
with contextlib.suppress(Exception):
from hermes_cli.update_receipt import record_step
record_step("update_custody", False, detail)
if _RUN["refused"] is None:
_RUN["refused"] = (exc, not _RUN["ran"] and not _past_commit())
return exc
def _past_commit() -> bool:
"""The update reached its commit point: its receipt records the ``apply`` stage (the
completion child resumes that receipt). No receipt is open before an update begins, nor in
the parent after its completion child returned, which starts no update child."""
try:
from hermes_cli.update_receipt import _current
current = _current.get()
except Exception: # health: allow BLE001 -- fail closed: unknown = past commit, never "nothing changed"
return True
return current is not None and any(stage.get("name") == "apply" for stage in current.data.get("stages") or ())
def refusal_notice(command: str = "hermes update") -> str | None:
"""What the command's failure path prints when this run refused an update child (m2), in
place of whatever generic error the refusal turned into downstream; ``None`` otherwise."""
if _RUN["refused"] is None:
return None
exc, unchanged = _RUN["refused"]
outcome = ("Nothing was changed: no update step had run yet." if unchanged else
"Update steps before it had already run.")
return f"✗ `{command}` stopped: {exc.reason}\n {outcome}\n {_RETRY.format(command=f'`{command}`')}"
def git_subcommand(args: Sequence[str]) -> str | None:
"""The subcommand in ``args`` (everything after the git executable), skipping global options."""
it = iter(args)
for arg in it:
if arg in _GLOBAL_WITH_VALUE:
next(it, None)
elif not arg.startswith("-"):
return arg
return None
def is_local_mutator(args: Sequence[str]) -> bool:
return git_subcommand(args) in LOCAL_MUTATORS
def git_argv(git_cmd: Sequence[str], args: Sequence[str]) -> list[str]:
"""``git_cmd + args`` with the custody config inserted before the subcommand."""
git_cmd, args = list(git_cmd), list(args)
extra = GIT_NO_DETACH + (_MUTATOR_CONFIG if is_local_mutator(git_cmd[1:] + args) else ())
return [*git_cmd, *extra, *args]
def _held() -> dict | None:
try:
from hermes_cli import update_lock
except Exception: # health: allow BLE001 -- a torn tree's half-written module raises anything; no updater runs
return None
return update_lock._HELD
def _death_signal_preexec():
"""Linux: a ``preexec_fn`` making the child die (SIGKILL) with the updater thread that waits
on it. For the git children that do NOT hold the lock fd (fetch, ls-remote, readers): a
killed owner must not leave a fetch rewriting refs under the next lock owner. The ruling's
stale-lock rules recover a fetch killed mid-write. ``None`` elsewhere (Windows: the job;
macOS has no parent-death signal: :func:`run_git` puts a fetch under :func:`_owner_watch`)."""
if not sys.platform.startswith("linux"):
return None
import ctypes
import os
prctl = ctypes.CDLL(None, use_errno=True).prctl # resolved before fork: the child only calls
parent = os.getpid()
def _arm():
prctl(1, 9, 0, 0, 0) # PR_SET_PDEATHSIG, SIGKILL
if os.getppid() != parent: # the owner died between fork and prctl
os._exit(137)
return _arm
def _custody_kwargs(inherit_lock: bool, kwargs: dict) -> dict:
fds = _lock_fds(inherit_lock)
if fds:
kwargs["pass_fds"] = tuple(dict.fromkeys((*kwargs.get("pass_fds", ()), *fds)))
elif not inherit_lock and _held() is not None and "preexec_fn" not in kwargs:
arm = _death_signal_preexec()
if arm is not None:
kwargs["preexec_fn"] = arm
return kwargs
def _lock_fds(inherit_lock: bool) -> tuple[int, ...]:
if not inherit_lock or sys.platform == "win32":
return ()
from hermes_cli.update_lock import custody_spawn_kwargs
return tuple(custody_spawn_kwargs().get("pass_fds", ()))
def _bind_suspended(proc: subprocess.Popen) -> None:
"""Assign a CREATE_SUSPENDED child to the update's kill-on-close job, then resume it.
Suspended until bound, so nothing it spawns can escape the job. A refused bind kills the
child before it ran a single instruction and raises :class:`CustodyRefused` (D2: never an
unfenced writer); a failed resume kills the child."""
from hermes_cli.update_lock import _bind_to_kill_on_close_job, resume_suspended_child
try:
_bind_to_kill_on_close_job(proc)
except OSError as exc:
proc.kill()
proc.wait()
raise _refuse(proc.args, exc) from exc
resume_suspended_child(proc)
_RUN["ran"] = True
def popen(argv: Sequence[str], *, inherit_lock: bool = False, **kwargs) -> subprocess.Popen:
"""``subprocess.Popen`` in the update tree's custody (see the module doc)."""
if sys.platform == "win32" and _held() is not None:
kwargs["creationflags"] = kwargs.get("creationflags", 0) | _CREATE_SUSPENDED
proc = subprocess.Popen(list(argv), **kwargs)
try:
_bind_suspended(proc)
except BaseException:
with contextlib.suppress(OSError):
proc.kill()
raise
return proc
return subprocess.Popen(list(argv), **_custody_kwargs(inherit_lock, kwargs))
def run(argv: Sequence[str], *, inherit_lock: bool = False, **kwargs) -> subprocess.CompletedProcess:
"""``subprocess.run`` in the update tree's custody. Off Windows (or outside an update) it IS
``subprocess.run`` with the caller's kwargs untouched, plus the lock fd for mutators (or the
parent-death signal for the rest); on Windows inside an update it is the same contract over
:func:`popen`, so the child is job-bound before it runs."""
if not (sys.platform == "win32" and _held() is not None):
return subprocess.run(list(argv), **_custody_kwargs(inherit_lock, kwargs)) # noqa: PLW1510 -- forwarding wrapper: callers pass subprocess kwargs via **kwargs (check may arrive through it); an explicit check=False would raise TypeError
input, timeout = kwargs.pop("input", None), kwargs.pop("timeout", None)
check = kwargs.pop("check", False)
if input is not None:
kwargs["stdin"] = subprocess.PIPE
if kwargs.pop("capture_output", False):
kwargs["stdout"] = kwargs["stderr"] = subprocess.PIPE
with popen(argv, **kwargs) as proc:
try:
stdout, stderr = proc.communicate(input, timeout=timeout)
except subprocess.TimeoutExpired as exc:
# git.exe's git-remote-https inherits the pipes: kill the tree, not git.exe alone (and
# never the job: it also holds the update's other children).
from hermes_cli._subprocess_compat import kill_and_drain
drained = kill_and_drain(proc, _DRAIN_SECONDS)
if drained is not None:
exc.stdout, exc.stderr = drained
raise
except BaseException:
proc.kill()
raise
code = proc.poll()
if check and code:
raise subprocess.CalledProcessError(code, proc.args, output=stdout, stderr=stderr)
return subprocess.CompletedProcess(proc.args, code, stdout, stderr)
def popen_post_commit(argv: Sequence[str], *, label: str, **kwargs) -> subprocess.Popen:
"""``subprocess.Popen`` for a checkout writer that runs after the update committed (the
historical takeover's ``update_finish``). POSIX: the lock fd. Windows inside an update:
created suspended and bound to the update's kill-on-close job before it runs. A refused bind
must not fail a committed update, so the child still runs, never silently (printed and
receipted) and never unfenced: it joins the checkout lock holding its own lease (R5b)."""
if not (sys.platform == "win32" and _held() is not None):
return subprocess.Popen(list(argv), **_custody_kwargs(True, kwargs))
from hermes_cli.update_lock import bind_child_to_update_tree, resume_suspended_child
kwargs["creationflags"] = kwargs.get("creationflags", 0) | _CREATE_SUSPENDED
proc = subprocess.Popen(list(argv), **kwargs)
try:
refusal = bind_child_to_update_tree(proc)
if refusal is not None:
detail = (f"the update's job would not take the {label} ({refusal}), so it runs outside "
"the job, holding its own checkout lease")
print(f" ⚠ {detail}", flush=True)
with contextlib.suppress(Exception):
from hermes_cli.update_receipt import record_step
record_step("update_custody", False, detail)
resume_suspended_child(proc)
except BaseException:
proc.kill()
proc.wait()
raise
return proc
# How long a timed-out child's output may still drain after its tree was killed.
_DRAIN_SECONDS = 5
def run_git(git_cmd: Sequence[str], args: Sequence[str], **kwargs) -> subprocess.CompletedProcess:
"""THE updater git runner: custody config in argv, the lock fd only into local mutators,
Windows job binding inside an update. ``kwargs`` are ``subprocess.run``'s."""
argv = git_argv(git_cmd, args)
mutator = is_local_mutator(argv[1:])
if mutator:
_prefetch_for_move(list(git_cmd), list(args), kwargs)
elif git_subcommand(argv[1:]) in _FD_LESS_REF_WRITERS and sys.platform != "win32" \
and _held() is not None and "preexec_fn" not in kwargs and _death_signal_preexec() is None:
return _run_owner_watched(argv, kwargs)
return run(argv, inherit_lock=mutator, **kwargs)
# git commands that write refs WITHOUT the lock fd (m3: their network half must not hold it). On
# Linux the parent-death signal ends one whose owner died; where there is none (macOS) a watchdog
# does (R3), or it would go on rewriting refs and leave live `*.lock` files under the next owner.
_FD_LESS_REF_WRITERS = frozenset({"fetch"})
# The watchdog: stdin is a pipe only the owner writes. A byte = the child finished; EOF with no
# byte = the owner died (the kernel closed its end), so SIGKILL the child, as PR_SET_PDEATHSIG
# would. It holds no lock fd and runs in its own session (a terminal's ^C/hang-up is the owner's).
_OWNER_WATCH = (
"import os, signal, sys\n"
"if not os.read(0, 1):\n"
" try:\n"
" os.kill(int(sys.argv[1]), signal.SIGKILL)\n" # windows-footgun: ok -- POSIX only (run_git gates win32 out)
" except OSError:\n"
" pass\n"
)
def _owner_watch(pid: int):
"""Start the parent-death stand-in for child ``pid``; returns the callable that dismisses it
once the child is done, or ``None`` when it could not start (logged: the fetch still runs, as
it did before; only the owner's death mid-fetch is then unfenced)."""
import os
read_end, write_end = os.pipe() # non-inheritable: no other child keeps the owner's end open
try:
watcher = subprocess.Popen([sys.executable, "-I", "-S", "-c", _OWNER_WATCH, str(pid)], stdin=read_end,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
except OSError as exc:
os.close(write_end)
logger.warning("Could not watch update child %s for its owner's death: %s", pid, exc)
return None
finally:
os.close(read_end)
def dismiss() -> None:
with contextlib.suppress(OSError): # the watcher already gone: nothing left to tell it
os.write(write_end, b"x")
os.close(write_end)
with contextlib.suppress(subprocess.TimeoutExpired): # it exits on the byte; never block on it
watcher.wait(timeout=5)
return dismiss
def _run_owner_watched(argv: Sequence[str], kwargs: dict) -> subprocess.CompletedProcess:
"""``subprocess.run`` for an fd-less ref writer, under :func:`_owner_watch` while it runs."""
input, timeout = kwargs.pop("input", None), kwargs.pop("timeout", None)
check = kwargs.pop("check", False)
if input is not None:
kwargs["stdin"] = subprocess.PIPE
if kwargs.pop("capture_output", False):
kwargs["stdout"] = kwargs["stderr"] = subprocess.PIPE
with subprocess.Popen(list(argv), **kwargs) as proc:
dismiss = _owner_watch(proc.pid)
try:
stdout, stderr = proc.communicate(input, timeout=timeout)
except subprocess.TimeoutExpired as exc:
proc.kill()
try:
exc.stdout, exc.stderr = proc.communicate(timeout=_DRAIN_SECONDS)
except subprocess.TimeoutExpired:
# A helper git started still holds the pipes: leave them to the reader threads.
proc.stdin = proc.stdout = proc.stderr = None
raise
except BaseException:
proc.kill()
raise
finally:
if dismiss is not None:
dismiss()
code = proc.poll()
if check and code:
raise subprocess.CalledProcessError(code, proc.args, output=stdout, stderr=stderr)
return subprocess.CompletedProcess(proc.args, code, stdout, stderr)
def _partial_clone(git_cmd: Sequence[str], kwargs: dict) -> bool:
"""The repository has a promisor remote (``extensions.partialClone``), read from disk."""
from pathlib import Path
from hermes_cli.update_lock import _git_common_dir
cwd = kwargs.get("cwd")
for flag, value in itertools.pairwise(git_cmd):
if flag == "-C":
cwd = Path(cwd or ".") / value
common = _git_common_dir(Path(cwd or "."))
try:
return common is not None and "partialclone" in \
(common / "config").read_text(encoding="utf-8-sig", errors="replace").lower()
except OSError:
return False
def _prefetch_for_move(git_cmd: list[str], args: list[str], kwargs: dict) -> None:
"""Partial clones: fetch what a move to another commit will read — the target's changed trees
and blobs — WITHOUT the lock fd and with the user's credential helpers, so the mutator (which
has neither helper nor reason to fetch) never starts a lazy fetch or a credential daemon under
the fd (m3). ``git diff HEAD <target>`` reads exactly the trees and blobs that differ from the
checked-out tree; the rest is local. Best effort: a miss only leaves a helper-less lazy fetch."""
sub = git_subcommand(args)
if sub not in _MOVES or not _partial_clone(git_cmd, kwargs):
return
common = {key: kwargs[key] for key in ("cwd", "env") if key in kwargs}
def resolve(*query: str, stdin: str | None = None) -> str | None:
out = run(git_argv(git_cmd, list(query)), capture_output=True, text=True, encoding="utf-8",
errors="replace", input=stdin, **({} if stdin is not None else {"stdin": subprocess.DEVNULL}),
**common)
return out.stdout.strip() if out.returncode == 0 and out.stdout.strip() else None
try:
targets = []
for arg in args[args.index(sub) + 1:]:
if arg == "--":
break
if not arg.startswith("-"):
targets.append(resolve("rev-parse", "--verify", "-q", f"{arg}^{{commit}}"))
targets = [target for target in dict.fromkeys(targets) if target]
if not targets:
return
# An unborn HEAD (a fresh clone) has nothing checked out: diff from the empty tree.
base = resolve("rev-parse", "--verify", "-q", "HEAD^{commit}") \
or resolve("hash-object", "-t", "tree", "--stdin", stdin="")
for target in targets:
if base and target != base:
run(git_argv(git_cmd, ["diff", "--binary", "--no-ext-diff", "--no-textconv", base, target]),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **common)
except (OSError, ValueError, subprocess.SubprocessError) as exc:
logger.debug("Could not prefetch the objects for git %s: %s", sub, exc)
# A stdlib launcher for children whose Popen the updater never sees (``run_contained``): it
# joins the job named by an inherited handle, starts the real command suspended with the same
# stdio, runs it only once it is in the job, and drops that handle (only the owner's handle may
# keep the job open). A refused join, or a command outside the job, never runs (D2): the launcher says so on stderr, writes the notice to the report file
# (argv[2]) — run_contained captures the child's stderr, so stderr alone is not seen (m1) — and
# exits; the updater logs the notice, notes it in the receipt and raises CustodyRefused.
# The command also runs in a kill-on-close job of the launcher's own, nested in the update job
# (review C3): once the command exits, the launcher terminates that job — whatever npm / the
# builder started and left running — and waits until no process of it is left before it
# returns, so the caller never releases the checkout while a build descendant still writes.
# Only the command's tree: the update job also holds the update's other children. The Windows
# twin of the POSIX launcher below (_REAP_TREE); the nested job's handle closing (a killed
# launcher) kills the tree too.
_CUSTODY_UNAVAILABLE = "hermes: update custody unavailable"
_REFUSED_EXIT = 87
_JOIN_JOB = (
"import ctypes, subprocess, sys, time\n"
"k = ctypes.WinDLL('kernel32', use_last_error=True)\n"
"k.AssignProcessToJobObject.argtypes = [ctypes.c_void_p, ctypes.c_void_p]\n"
"k.GetCurrentProcess.restype = ctypes.c_void_p\n"
"k.CloseHandle.argtypes = [ctypes.c_void_p]\n"
"k.CreateJobObjectW.restype = ctypes.c_void_p\n"
"k.SetInformationJobObject.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_ulong]\n"
"k.TerminateJobObject.argtypes = [ctypes.c_void_p, ctypes.c_uint]\n"
"k.QueryInformationJobObject.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_ulong,\n"
" ctypes.c_void_p]\n"
"h = ctypes.c_void_p(int(sys.argv[1]))\n"
"def refuse(why):\n"
f" note = '{_CUSTODY_UNAVAILABLE} (%s); the command was not run' % why\n"
" sys.stderr.write(note + '\\n')\n"
" sys.stderr.flush()\n"
" try:\n"
" with open(sys.argv[2], 'w', encoding='utf-8') as report:\n"
" report.write(note)\n"
" except OSError:\n"
" pass\n"
f" sys.exit({_REFUSED_EXIT})\n"
"if not k.AssignProcessToJobObject(h, k.GetCurrentProcess()):\n"
" refuse('could not join the update job: %d' % ctypes.get_last_error())\n"
# JOBOBJECT_EXTENDED_LIMIT_INFORMATION (KILL_ON_JOB_CLOSE) and the BASIC_ACCOUNTING record
# whose ActiveProcesses says when the command's tree is gone.
"class Basic(ctypes.Structure):\n"
" _fields_ = [('times', ctypes.c_int64 * 2), ('flags', ctypes.c_uint32), ('ws', ctypes.c_size_t * 2),\n"
" ('procs', ctypes.c_uint32), ('affinity', ctypes.c_size_t), ('classes', ctypes.c_uint32 * 2)]\n"
"class Limits(ctypes.Structure):\n"
" _fields_ = [('basic', Basic), ('io', ctypes.c_uint64 * 6), ('memory', ctypes.c_size_t * 4)]\n"
"class Usage(ctypes.Structure):\n"
" _fields_ = [('times', ctypes.c_int64 * 4), ('faults', ctypes.c_uint32), ('total', ctypes.c_uint32),\n"
" ('active', ctypes.c_uint32), ('terminated', ctypes.c_uint32)]\n"
"tree = k.CreateJobObjectW(None, None)\n"
"limits = Limits()\n"
"limits.basic.flags = 0x2000\n"
"if not tree or not k.SetInformationJobObject(tree, 9, ctypes.byref(limits), ctypes.sizeof(limits)):\n"
" refuse('could not create the job for the command tree: %d' % ctypes.get_last_error())\n"
# Verify, never assume the topology: a packaged interpreter (Store Python) joins, but starts a
# program outside its package with desktop-app breakaway, so the command leaves every job
# that permits breakaway, as the update job does (restarted gateways break away). Start the
# command suspended; if Windows says it is outside the job, put it in explicitly (F80), and
# run it only once it is in (F54): a command the job will not take is refused, never run.
"k.IsProcessInJob.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)]\n"
"p = subprocess.Popen(sys.argv[3:], stdin=subprocess.DEVNULL, creationflags=0x4)\n"
"def in_job():\n"
" inside = ctypes.c_int(0)\n"
" return k.IsProcessInJob(ctypes.c_void_p(int(p._handle)), h, ctypes.byref(inside)) and inside.value\n"
"if not in_job() and not (k.AssignProcessToJobObject(h, ctypes.c_void_p(int(p._handle))) and in_job()):\n"
" p.kill()\n"
" p.wait()\n"
" refuse('the command would start outside the update job')\n"
"if not k.AssignProcessToJobObject(tree, ctypes.c_void_p(int(p._handle))):\n"
" error = ctypes.get_last_error()\n"
" p.kill()\n"
" p.wait()\n"
" refuse('could not hold the command tree in a job: %d' % error)\n"
"k.CloseHandle(h)\n"
"nt = ctypes.WinDLL('ntdll')\n"
"nt.NtResumeProcess.argtypes = [ctypes.c_void_p]\n"
"if nt.NtResumeProcess(ctypes.c_void_p(int(p._handle))) != 0:\n"
" p.kill()\n"
"code = p.wait()\n"
# C3: nothing the command started outlives it; return only once its whole tree is gone.
"k.TerminateJobObject(tree, 1)\n"
"usage, end = Usage(), time.monotonic() + 10\n"
"while time.monotonic() < end:\n"
" if not k.QueryInformationJobObject(tree, 1, ctypes.byref(usage), ctypes.sizeof(usage), None):\n"
" break\n"
" if not usage.active:\n"
" break\n"
" time.sleep(0.02)\n"
"sys.exit(code)\n"
)
def _report_refused_join(report: str, argv: Sequence[str]) -> str | None:
"""Make a refused job join visible: a warning (errors.log) and a receipt step. The notice,
or None when the child joined (or never ran the launcher)."""
import os
try:
with open(report, encoding="utf-8-sig") as fh:
note = fh.read().strip()
except OSError:
note = ""
finally:
with contextlib.suppress(OSError):
os.unlink(report)
if not note:
return None
logger.warning("%s: %s", note, " ".join(str(arg) for arg in argv[:2]))
with contextlib.suppress(Exception):
from hermes_cli.update_receipt import record_step
record_step("update_custody", False, f"{note}: {' '.join(str(arg) for arg in argv[:2])}")
return note
def _join_launcher_python() -> str:
"""The interpreter for the job-joining launcher: the real one, never a venv redirector.
A venv's ``Scripts\\python.exe`` is a redirector: it starts the base interpreter as its own
child, inside a job of its own. That child cannot join the update's job once the job holds a
process from another job hierarchy (the updater's git children): ``AssignProcessToJobObject``
fails with ERROR_ACCESS_DENIED (5). And the redirector, never joined, keeps its inherited copy
of the job handle open, so the job would not close when the owner dies. The launcher is
stdlib-only (``-I -S``), so the base interpreter runs it as is.
"""
import os
base = getattr(sys, "_base_executable", None)
if base and os.path.isfile(base):
return base
return sys.executable
# POSIX twin of the launcher (N13, review L1/L6). Node marks the donated lock fd close-on-exec,
# so nothing node starts (npm, esbuild, sh) keeps the checkout locked: custody of them rests on
# this stdlib parent, which holds the fd until no descendant of the command is left.
# * The command stays in the CALLER's process group: every group kill that stops a build (a
# Ctrl-C'd completion child's ``killpg``, Desktop's ``kill(-pid)``) reaches node and everything
# under it that did not start a session of its own. The custodian leaves that group (E): a
# SIGKILL of the group would otherwise kill it too, and a descendant in a session of its own
# would go on writing with the checkout lock free. It outlives the kill, settles the tree as
# below, and only then exits (releasing the fd).
# * Linux: the custodian is a child subreaper, so a descendant orphaned by node's exit (or by any
# intermediate's) is re-parented to it, never to init. Once node exits it SIGKILLs and reaps
# its children until none is left; it only signals its own unreaped children, so no pid it
# kills can have been reused (never a ``killpg`` of an already reaped leader's group).
# * Elsewhere (macOS has no subreaper) it records node's descendant tree from ``ps`` while node
# runs and, once node exits, kills every recorded process whose start time still matches,
# plus what they started. Best effort: a process born and orphaned between two samples escapes.
# * SIGINT/SIGTERM/SIGHUP are forwarded to node (a node still running 10 s later is killed); the
# descendants are then killed as above.
# * The custodian is NOT the process the runner sees (R4): ``subprocess.run`` SIGKILLs its own
# child when Ctrl-C reaches the caller, and no group separation survives a direct kill. The
# runner's child only forks the custodian (own group) and relays signals to it and its exit
# status back; it leaves the caller's group too, or a terminal's Ctrl-C would reach node twice. Killed, it leaves the custodian to settle: node gets the same 10 s it gets after
# a forwarded signal (it is usually still cleaning up from the same Ctrl-C), then the tree is
# killed as above and only then is the fd dropped.
_REAP_TREE = r"""
import os, signal, subprocess, sys, time
KILL = signal.SIGKILL # windows-footgun: ok - POSIX-only launcher
fds = tuple(int(fd) for fd in sys.argv[1].split(','))
caller = os.getpgrp()
if caller != os.getpid():
os.setpgid(0, 0)
stand_in = os.getpid()
custodian = os.fork()
if custodian:
def relay(signum, frame):
try:
os.kill(custodian, signum)
except OSError:
pass
for name in ('SIGINT', 'SIGTERM', 'SIGHUP'):
signal.signal(getattr(signal, name), relay)
code = os.waitstatus_to_exitcode(os.waitpid(custodian, 0)[1])
sys.exit(code if code >= 0 else 128 - code)
os.setpgid(0, 0)
me = os.getpid()
reaper = False
if sys.platform.startswith('linux'):
try:
import ctypes
reaper = ctypes.CDLL(None, use_errno=True).prctl(36, 1, 0, 0, 0) == 0 # PR_SET_CHILD_SUBREAPER
except (OSError, AttributeError):
reaper = False
def children():
found = []
for entry in os.listdir('/proc'):
if not entry.isdigit():
continue
try:
with open('/proc/%s/stat' % entry, 'rb') as fh:
stat = fh.read()
if int(stat[stat.rindex(b')') + 2:].split()[1]) == me:
found.append(int(entry))
except (OSError, ValueError, IndexError):
continue
return found
def table():
try:
out = subprocess.run(['ps', '-A', '-o', 'pid=,ppid=,stat=,lstart='], stdin=subprocess.DEVNULL,
capture_output=True, text=True, errors='replace', timeout=10).stdout
except (OSError, subprocess.SubprocessError):
return {}
rows = {}
for line in out.splitlines():
parts = line.split(None, 3)
if len(parts) == 4 and parts[0].isdigit() and parts[1].isdigit() and not parts[2].startswith('Z'):
rows[int(parts[0])] = (int(parts[1]), parts[3].strip())
return rows
seen = {}
def sample(root):
rows = table()
tree = {pid for pid, start in seen.items() if rows.get(pid, (0, None))[1] == start}
if root is not None:
tree.add(root)
grew = True
while grew:
grew = False
for pid, (ppid, start) in rows.items():
if ppid in tree and pid not in tree:
tree.add(pid)
seen[pid] = start
grew = True
return [pid for pid, start in seen.items() if rows.get(pid, (0, None))[1] == start]
def kill(pids):
for pid in pids:
try:
os.kill(pid, KILL)
except OSError:
pass
p = subprocess.Popen(sys.argv[2:], pass_fds=fds, process_group=caller)
got = []
def forward(signum, frame):
got.append(signum)
try:
p.send_signal(signum)
except OSError:
pass
for name in ('SIGINT', 'SIGTERM', 'SIGHUP'):
signal.signal(getattr(signal, name), forward)
deadline = None
while True:
try:
code = p.wait(timeout=0.5)
break
except subprocess.TimeoutExpired:
pass
if not reaper:
sample(p.pid)
if got or os.getppid() != stand_in:
deadline = deadline or time.monotonic() + 10
if time.monotonic() > deadline:
p.kill()
end = time.monotonic() + 10
while time.monotonic() < end:
live = children() if reaper else sample(None)
if not live:
break
kill(live)
while reaper:
try:
if not os.waitpid(-1, os.WNOHANG)[0]:
break
except ChildProcessError:
break
time.sleep(0.02)
sys.exit(code if code >= 0 else 128 - code)
"""
@contextlib.contextmanager
def contained_command(argv: Sequence[str], *, inherit_lock: bool = True, root=None):
"""``(argv, kwargs)`` for a checkout writer started by a runner that hides its Popen (the Node
build in ``pm.progress.run_contained``). POSIX: the lock fd (this process's, or one it
inherited for checkout ``root``), under a launcher that keeps the command in the caller's
process group and kills every descendant left when it exits (:data:`_REAP_TREE`).
Windows inside an update: the command runs under a launcher that joins the update's
kill-on-close job first and returns only once no process the command started is left (C3);
when the job cannot be handed over or the join is refused, the command never runs and
:class:`CustodyRefused` is raised (D2)."""
argv = list(argv)
if not (sys.platform == "win32" and _held() is not None):
from hermes_cli.update_lock import checkout_lock_fds
fds = tuple(checkout_lock_fds(root)) if inherit_lock and root is not None else _lock_fds(inherit_lock)
if fds:
argv = [sys.executable, "-I", "-S", "-c", _REAP_TREE, ",".join(map(str, fds)), *argv]
yield argv, ({"pass_fds": fds} if fds else {})
return
try:
handle = _inheritable_job_handle()
except OSError as exc:
raise _refuse(argv, exc) from exc
import os
import tempfile
fd, report = tempfile.mkstemp(prefix="hermes-custody-", suffix=".txt")
os.close(fd)
try:
info = subprocess.STARTUPINFO()
info.lpAttributeList = {"handle_list": [handle]}
yield ([_join_launcher_python(), "-I", "-S", "-c", _JOIN_JOB, str(handle), report, *argv],
{"startupinfo": info})
finally:
import ctypes
ctypes.WinDLL("kernel32").CloseHandle(ctypes.c_void_p(handle))
note = _report_refused_join(report, argv)
if note:
cause = note.removeprefix(f"{_CUSTODY_UNAVAILABLE} (").partition(")")[0]
raise _refuse(argv, cause, receipted=True)
_RUN["ran"] = True
def _inheritable_job_handle() -> int:
"""An inheritable duplicate of the update's kill-on-close job handle (Windows). Raises
``OSError`` when the job cannot be created or duplicated."""
import ctypes
from hermes_cli.update_lock import update_tree_job
try:
job = update_tree_job()
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.GetCurrentProcess.restype = ctypes.c_void_p
kernel32.DuplicateHandle.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p,
ctypes.POINTER(ctypes.c_void_p), ctypes.c_ulong, ctypes.c_int,
ctypes.c_ulong]
me, dup = kernel32.GetCurrentProcess(), ctypes.c_void_p()
# DUPLICATE_SAME_ACCESS, inheritable
if not kernel32.DuplicateHandle(me, job, me, ctypes.byref(dup), 0, True, 0x2):
raise ctypes.WinError(ctypes.get_last_error())
return int(dup.value)
except OSError as exc:
logger.warning("Could not hand the update's job to a build child: %s", exc)
raise