Files
hermes-agent/tests/hermes_cli/test_update_custody.py
Teknium 61470f10a4 fix: a Ctrl-C'd build runner can no longer kill the custodian of its detached writers
`run_contained` starts the POSIX build launcher with `subprocess.run`, which SIGKILLs its own
child when KeyboardInterrupt escapes the wait. That child was the custodian holding the
checkout lock fd: moving it out of the caller's group (E) survives a group kill, not a direct
kill. A command still cleaning up from the same Ctrl-C then exited, dropped the last lock fd,
and a writer in a session of its own went on writing under the next lock owner (review R4).

The process the runner sees is now a stand-in: it forks the custodian into a group of its own,
relays SIGINT/SIGTERM/SIGHUP to it and returns its exit status. A killed stand-in leaves the
custodian to settle the tree: node gets the same 10 s it gets after a forwarded signal, then
every descendant is killed and reaped, and only then is the fd dropped.

Test: test_a_ctrl_c_keeps_custody_until_a_detached_writer_is_gone (verbose + captured runner,
slow and instant SIGINT cleanup; real run_in_custody, real SIGINT to the caller's group, real
flock contender) fails on bc9311b904c and passes here.
2026-10-06 04:36:02 -07:00

617 lines
32 KiB
Python

"""R2: every updater git runner shares ONE custody policy (hermes_cli.update_custody).
Real git, real processes, no mocks:
* every runner passes ``-c gc.autoDetach=false -c maintenance.auto=false`` (git reports the
effective config it was started with), so no detached gc/maintenance child is ever forked;
* POSIX: the checkout lock fd reaches only local mutators — a ``git fetch``'s upload-pack (where a
credential-cache daemon would hang) never holds it, a ``git stash push``'s clean filter does.
"""
from __future__ import annotations
import os
import subprocess
import sys
import textwrap
import time
from pathlib import Path
import pytest
from hermes_cli import update_lock as ul
REPO_ROOT = Path(__file__).resolve().parents[2]
def _git(cwd: Path, *args: str) -> str:
return subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True,
text=True, encoding="utf-8", env=_env(cwd)).stdout.strip()
def _env(base: Path) -> dict:
return dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull,
GIT_AUTHOR_NAME="t", GIT_AUTHOR_EMAIL="t@e", GIT_COMMITTER_NAME="t", GIT_COMMITTER_EMAIL="t@e")
@pytest.fixture
def repo(tmp_path, monkeypatch):
for key, value in _env(tmp_path).items():
monkeypatch.setenv(key, value)
root = tmp_path / "repo"
root.mkdir()
_git(root, "init", "-q", "-b", "main")
(root / "f.txt").write_text("one\n", encoding="utf-8")
_git(root, "add", "f.txt")
_git(root, "commit", "-qm", "one")
return root
def _runners(repo: Path):
"""Every updater git runner, as ``args -> stdout``."""
from hermes_cli import gitlock, update_cmd, update_cmd_check, update_cmd_git, update_cmd_stash
yield "update_cmd._git_run", lambda a: update_cmd._git_run(["git"], a, cwd=repo).stdout
yield "update_cmd_git._git_run", lambda a: update_cmd_git._git_run(["git"], a, cwd=repo).stdout
yield "update_cmd_stash._git_quiet", lambda a: update_cmd_stash._git_quiet(["git"], a, repo, text=True).stdout
yield "update_cmd_check._git", lambda a: update_cmd_check._git(["git"], repo, a).stdout
yield "gitlock._git_stdout_lines", lambda a: "\n".join(gitlock._git_stdout_lines(repo, a))
def test_every_updater_git_runner_forbids_detached_children(repo):
missing = {}
for name, run in _runners(repo):
seen = {key: run(["config", "--get", key]).strip() for key in ("gc.autoDetach", "maintenance.auto")}
if seen != {"gc.autoDetach": "false", "maintenance.auto": "false"}:
missing[name] = seen
assert not missing, f"updater git runners that may fork a detached gc/maintenance child: {missing}"
# Records whether the checkout lock file is among this process's open fds, then hands over.
_RECORDER = textwrap.dedent("""\
#!/bin/sh
lock="$HERMES_TEST_LOCK"; out="$HERMES_TEST_OUT"
held=no
for fd in /proc/$$/fd/*; do
[ "$(readlink "$fd")" = "$lock" ] && held=yes
done
echo "$held" >> "$out"
exec "$@"
""")
@pytest.mark.skipif(not Path("/proc/self/fd").is_dir(), reason="needs /proc fd listing")
def test_lock_fd_reaches_local_mutators_only(repo, tmp_path, monkeypatch):
from hermes_cli import update_cmd
recorder = tmp_path / "recorder.sh"
recorder.write_text(_RECORDER, encoding="utf-8")
recorder.chmod(0o755)
remote = tmp_path / "remote.git"
subprocess.run(["git", "clone", "-q", "--bare", str(repo), str(remote)], check=True, env=_env(tmp_path))
_git(repo, "remote", "add", "origin", str(remote))
_git(repo, "config", "filter.rec.clean", f"{recorder} cat")
(repo / ".git" / "info" / "attributes").write_text("f.txt filter=rec\n", encoding="utf-8")
out_fetch, out_stash = tmp_path / "fetch.out", tmp_path / "stash.out"
lock = ul.UpdateLock(path=tmp_path / "marker", install_root=repo)
assert lock.acquire()
try:
monkeypatch.setenv("HERMES_TEST_LOCK", os.path.realpath(ul.checkout_lock_path(repo)))
monkeypatch.setenv("HERMES_TEST_OUT", str(out_fetch))
fetched = update_cmd._git_run(["git"], ["fetch", f"--upload-pack={recorder} git-upload-pack", "origin"],
cwd=repo, network=True)
assert fetched.returncode == 0, fetched.stderr
monkeypatch.setenv("HERMES_TEST_OUT", str(out_stash))
(repo / "f.txt").write_text("two\n", encoding="utf-8")
stashed = update_cmd._git_run(["git"], ["stash", "push", "-m", "custody"], cwd=repo)
assert stashed.returncode == 0, stashed.stderr
finally:
lock.release()
assert out_fetch.read_text(encoding="utf-8-sig").split() == ["no"], "a fetch child (credential/daemon territory) held the lock"
assert "yes" in out_stash.read_text(encoding="utf-8-sig").split(), "a local mutator's child lost the checkout lock"
@pytest.mark.platforms("posix")
@pytest.mark.live_system_guard_bypass # kills the orphaned hook child it planted
def test_a_background_hook_never_keeps_a_completed_update_locked(repo, tmp_path):
"""F1: a local mutator holds the lock fd, and a repository hook it ran inherited that fd; a
hook that backgrounds a daemon kept the checkout locked after the merge returned and the
owner released, so the next update was refused. Once the owner releases, a contender acquires."""
import signal
base = _git(repo, "rev-parse", "HEAD")
(repo / "f.txt").write_text("two\n", encoding="utf-8")
_git(repo, "commit", "-qam", "two")
target = _git(repo, "rev-parse", "HEAD")
_git(repo, "reset", "-q", "--hard", base)
pid_file = tmp_path / "hook-child.pid"
hook = repo / ".git" / "hooks" / "post-merge"
hook.write_text(f"#!/bin/sh\nsleep 60 </dev/null >/dev/null 2>&1 &\necho $! > {pid_file}\n", encoding="utf-8")
hook.chmod(0o755)
from hermes_cli.update_custody import run_git
lock = ul.UpdateLock(path=tmp_path / "marker", install_root=repo)
assert lock.acquire()
try:
merged = run_git(["git"], ["merge", "--ff-only", target], cwd=repo, capture_output=True, text=True, timeout=30)
assert merged.returncode == 0, merged.stderr
finally:
lock.release()
contender = ul.UpdateLock(path=tmp_path / "next-marker", install_root=repo)
try:
assert (repo / "f.txt").read_text(encoding="utf-8") == "two\n"
assert contender.acquire(), f"a background git hook kept the completed update's checkout locked: {contender.holder}"
finally:
contender.release()
if pid_file.exists():
os.kill(int(pid_file.read_text(encoding="utf-8")), signal.SIGKILL) # windows-footgun: ok - POSIX-only test
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="parent-death signal is Linux-only")
def test_network_git_dies_with_its_killed_owner(repo, tmp_path):
"""The fd-less fetch must not keep rewriting refs after the owner died (the next owner's lease)."""
_assert_fetch_dies_with_killed_owner(repo, tmp_path, "")
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="reads the fetch's pid from /proc")
def test_network_git_dies_with_its_killed_owner_without_a_parent_death_signal(repo, tmp_path):
"""R3: where there is no parent-death signal (macOS), the fetch must still die with its owner."""
_assert_fetch_dies_with_killed_owner(
repo, tmp_path, "from hermes_cli import update_custody\nupdate_custody._death_signal_preexec = lambda: None\n")
def _assert_fetch_dies_with_killed_owner(repo, tmp_path, prelude: str) -> None:
script = textwrap.dedent(f"""
import sys
sys.path.insert(0, {str(REPO_ROOT)!r})
from pathlib import Path
from hermes_cli import update_lock as ul, update_cmd
""") + prelude + textwrap.dedent("""
lock = ul.UpdateLock(path=Path(sys.argv[2]), install_root=sys.argv[1])
assert lock.acquire()
update_cmd._git_run(["git"], ["fetch", "--upload-pack=" + sys.argv[3], "origin"], cwd=sys.argv[1], network=True)
""")
remote = tmp_path / "remote.git"
subprocess.run(["git", "clone", "-q", "--bare", str(repo), str(remote)], check=True, env=_env(tmp_path))
_git(repo, "remote", "add", "origin", str(remote))
started = tmp_path / "upload-pack.pid"
blocker = tmp_path / "block.sh"
blocker.write_text(f"#!/bin/sh\necho $$ > {started}.self\necho $PPID > {started}\nsleep 60\nexec git-upload-pack \"$@\"\n", encoding="utf-8")
blocker.chmod(0o755)
owner = subprocess.Popen([sys.executable, "-c", script, str(repo), str(tmp_path / "marker"), str(blocker)],
env=_env(tmp_path))
try:
for _ in range(300):
if started.exists() and started.read_text(encoding="utf-8-sig").strip():
break
time.sleep(0.05)
# git runs the upload-pack command through `sh -c`: the fetch is the shell's parent.
shell = int(started.read_text(encoding="utf-8-sig"))
fetch_pid = next(int(line.split()[1]) for line in Path(f"/proc/{shell}/status").read_text(encoding="utf-8-sig").splitlines()
if line.startswith("PPid:"))
assert "git" in Path(f"/proc/{fetch_pid}/cmdline").read_text(encoding="utf-8-sig", errors="replace")
owner.kill()
owner.wait(timeout=10)
for _ in range(100):
if not Path(f"/proc/{fetch_pid}").exists() or "Z" in _state(fetch_pid):
break
time.sleep(0.05)
assert not Path(f"/proc/{fetch_pid}").exists() or "Z" in _state(fetch_pid), \
"git fetch outlived its killed update owner"
finally:
if owner.poll() is None:
owner.kill()
blocker_pid = Path(f"{started}.self")
if blocker_pid.exists():
subprocess.run(["kill", "-9", blocker_pid.read_text(encoding="utf-8-sig").strip()], capture_output=True)
def _state(pid: int) -> str:
try:
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8-sig")
except OSError:
return "Z"
return stat[stat.rfind(")") + 2:][:1]
def test_a_refused_build_join_is_logged_and_noted_in_the_receipt(tmp_path, monkeypatch, caplog):
"""m1: the job-joining launcher's notice used to reach only the build's captured stderr
(run_contained merges it into the output it drops on success). The report it leaves is now
a warning (errors.log) and a receipt step; the build's outcome is untouched."""
import json
import logging
import hermes_cli.update_receipt as ur
from hermes_cli import update_custody
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
report = tmp_path / "report.txt"
note = f"{update_custody._CUSTODY_UNAVAILABLE} (could not join the update job: 5); the command was not run"
report.write_text(note, encoding="utf-8")
with ur.update_receipt_scope():
ur.begin_update_receipt()
with caplog.at_level(logging.WARNING, logger="hermes_cli.update_custody"):
assert update_custody._report_refused_join(str(report), ["node", "scripts/build/tui.mjs"]) == note
payload = json.loads(ur.finalize_update_receipt("success").read_text(encoding="utf-8-sig"))
assert any(note in rec.getMessage() and "tui.mjs" in rec.getMessage() for rec in caplog.records)
steps = [s for s in payload["steps"] if s["name"] == "update_custody"]
assert steps and steps[0]["ok"] is False and note in steps[0]["detail"], payload["steps"]
assert payload["outcome"] == "success"
assert not report.exists()
empty = tmp_path / "joined.txt"
empty.write_text("", encoding="utf-8")
assert update_custody._report_refused_join(str(empty), ["node"]) is None # joined: silent
def test_a_swallowed_refusal_is_what_the_update_reports(tmp_path, monkeypatch, capsys):
"""R8 m2: readers swallow an OSError (``_git_stdout``, ``_capture_head_sha``), so a refused
child used to end ``hermes update`` as whatever error followed downstream. The refusal is
receipted and kept for the run, and the command's failure path prints it, actionably."""
import contextlib
from types import SimpleNamespace
import hermes_cli.update_receipt as ur
from hermes_cli import main, update_cmd, update_custody, update_owning_install
monkeypatch.setattr(update_custody, "_RUN", {"ran": False, "refused": None})
monkeypatch.setattr(update_owning_install, "retarget_to_owning_install", lambda root: None)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / "checkout").mkdir()
monkeypatch.setattr(main, "PROJECT_ROOT", tmp_path / "checkout")
monkeypatch.setattr(main, "_update_preflight_handled", lambda args: False)
monkeypatch.setattr(main, "_install_hangup_protection", lambda **kw: None)
monkeypatch.setattr(main, "_finalize_update_output", lambda state: None)
def impl(args, gateway_mode):
ur.begin_update_receipt()
with contextlib.suppress(OSError): # a reader that swallows it, as _git_stdout does
raise update_custody._refuse(["git", "rev-parse"], OSError(6, "The handle is invalid"))
print("✗ Could not resolve HEAD") # the downstream error it turned into
raise SystemExit(1)
monkeypatch.setattr(update_cmd, "_cmd_update_impl", impl)
with ur.update_receipt_scope(), pytest.raises(SystemExit) as stop:
main.cmd_update(SimpleNamespace(gateway=False))
assert stop.value.code == 1
out = capsys.readouterr().out
assert "`hermes update` stopped: Windows would not put `git` in this update's process job" in out, out
assert "Nothing was changed" in out and "Run `hermes update` again from a regular terminal" in out, out
payload = ur.read_latest_receipt()
steps = [s for s in payload["steps"] if s["name"] == "update_custody"]
assert steps and steps[0]["ok"] is False and "rev-parse" in steps[0]["detail"], payload["steps"]
@pytest.mark.skipif(not Path("/proc/self/fd").is_dir(), reason="needs /proc fd listing")
def test_a_partial_clone_move_never_fetches_under_the_lock_fd(repo, tmp_path, monkeypatch):
"""m3: installer checkouts are partial clones, so `reset --hard <target>` used to fetch the
target's missing objects itself — as a descendant holding the lock fd, with the user's
credential helper (a `git credential-cache--daemon` then held the checkout for 900 s). The
objects are fetched first without the fd; the mutator runs with no helper and fetches nothing."""
recorder = tmp_path / "recorder.sh"
recorder.write_text(_RECORDER, encoding="utf-8")
recorder.chmod(0o755)
origin = tmp_path / "origin.git"
subprocess.run(["git", "clone", "-q", "--bare", str(repo), str(origin)], check=True, env=_env(tmp_path))
_git(origin, "config", "uploadpack.allowFilter", "true")
_git(origin, "config", "uploadpack.allowAnySHA1InWant", "true")
clone = tmp_path / "clone"
subprocess.run(["git", "clone", "-q", "--filter=blob:none", f"file://{origin}", str(clone)],
check=True, env=_env(tmp_path))
(repo / "new.txt").write_text("only on the remote\n", encoding="utf-8")
_git(repo, "add", "new.txt")
_git(repo, "commit", "-qm", "two")
_git(repo, "push", "-q", str(origin), "main")
_git(clone, "fetch", "-q", "origin")
_git(clone, "config", "remote.origin.uploadpack", f"{recorder} git-upload-pack")
missing = [o for o in _git(clone, "rev-list", "--objects", "--missing=print", "origin/main").split() if o[0] == "?"]
assert missing, "fixture: the new blob must be missing locally"
out = tmp_path / "upload-pack.out"
monkeypatch.setenv("HERMES_TEST_LOCK", os.path.realpath(ul.checkout_lock_path(clone)))
monkeypatch.setenv("HERMES_TEST_OUT", str(out))
from hermes_cli.update_custody import run_git
lock = ul.UpdateLock(path=tmp_path / "marker", install_root=clone)
assert lock.acquire()
try:
moved = run_git(["git"], ["reset", "--hard", "origin/main"], cwd=clone, capture_output=True, text=True)
assert moved.returncode == 0, moved.stderr
finally:
lock.release()
assert (clone / "new.txt").read_text(encoding="utf-8-sig") == "only on the remote\n"
served = out.read_text(encoding="utf-8-sig").split()
assert served and "yes" not in served, f"an upload-pack (a lazy fetch) ran holding the checkout lock: {served}"
assert "credential.helper=" in moved.args, moved.args
@pytest.mark.skipif(not Path("/proc/self/fd").is_dir(), reason="needs /proc fd listing")
@pytest.mark.parametrize(("args", "holds"), [(["gc", "--auto"], True), (["rev-parse", "HEAD"], False)])
def test_run_git_hands_the_lock_fd_to_local_mutators_only(repo, tmp_path, monkeypatch, args, holds):
"""Through the production runner: `gc` (packs refs, repacks) is a local mutator and runs
holding the checkout lock; a reader does not. `pull` is never one (its network half)."""
from hermes_cli.update_custody import LOCAL_MUTATORS, run_git
assert "pull" not in LOCAL_MUTATORS
git = tmp_path / "git-recorder.sh"
git.write_text(_RECORDER.replace('exec "$@"', 'exec git "$@"'), encoding="utf-8")
git.chmod(0o755)
out = tmp_path / "git.out"
monkeypatch.setenv("HERMES_TEST_LOCK", os.path.realpath(ul.checkout_lock_path(repo)))
monkeypatch.setenv("HERMES_TEST_OUT", str(out))
lock = ul.UpdateLock(path=tmp_path / "marker", install_root=repo)
assert lock.acquire()
try:
ran = run_git([str(git)], args, cwd=repo, capture_output=True, text=True)
finally:
lock.release()
assert ran.returncode == 0, ran.stderr
assert out.read_text(encoding="utf-8-sig").split() == (["yes"] if holds else ["no"]), args
class _Win32Sys:
"""``sys`` as one module sees it on Windows; everything but ``platform`` is the real module."""
platform = "win32"
def __getattr__(self, name):
return getattr(sys, name)
@pytest.mark.platforms("posix")
def test_a_timed_out_child_never_waits_on_a_grandchild_holding_its_pipes(monkeypatch):
"""N14: the in-update (Windows) run() killed only the direct child on a timeout, then waited
unbounded for the pipes a grandchild still held (git-remote-https under a stalled fetch).
Driven here through that branch with a POSIX grandchild the tree kill (taskkill) cannot reach."""
from hermes_cli import update_custody
# Only update_custody sees win32: patching sys.platform itself would send every lazy import
# in this window (the suite's Popen guard imports gateway.status) down its Windows branch.
monkeypatch.setattr(update_custody, "sys", _Win32Sys())
monkeypatch.setattr(update_custody, "_held", lambda: {"fd": None})
monkeypatch.setattr(update_custody, "popen", lambda argv, **kw: subprocess.Popen(list(argv), **kw))
started = time.monotonic()
with pytest.raises(subprocess.TimeoutExpired):
update_custody.run(["sh", "-c", "sleep 20 & sleep 20"], capture_output=True, timeout=0.5)
assert time.monotonic() - started < 15, "run() waited on a grandchild after the timeout"
def _held_lock_recorder(tmp_path: Path, name: str, body: str) -> Path:
"""An executable on a fresh PATH dir that records whether it holds the checkout lock fd."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir(exist_ok=True)
exe = bin_dir / name
exe.write_text(_RECORDER.replace('exec "$@"', body), encoding="utf-8")
exe.chmod(0o755)
return exe
@pytest.mark.skipif(not Path("/proc/self/fd").is_dir(), reason="needs /proc fd listing")
def test_no_build_descendant_outlives_the_build_call(repo, tmp_path, monkeypatch):
"""N13: node marks the donated lock fd close-on-exec, so npm/esbuild under it never hold the
checkout; custody rested on node alone. A node that dies leaving a writer behind must not
leave it running once the build call returns: the next lock owner would share the checkout."""
from hermes_cli.source_build import run_source_script
late = tmp_path / "late"
writer = f"import os, time; os.closerange(3, 4096); time.sleep(2); __import__('pathlib').Path({str(late)!r}).touch()"
node = _held_lock_recorder(tmp_path, "node", f"{sys.executable} -c \"{writer}\" >/dev/null 2>&1 &\nexit 1")
monkeypatch.setenv("HERMES_TEST_LOCK", os.path.realpath(ul.checkout_lock_path(repo)))
monkeypatch.setenv("HERMES_TEST_OUT", str(tmp_path / "node.out"))
lock = ul.UpdateLock(path=tmp_path / "marker", install_root=repo)
assert lock.acquire()
try:
with pytest.raises(subprocess.CalledProcessError):
run_source_script(repo, "build.mjs", env={**os.environ, "PATH": f"{node.parent}{os.pathsep}{os.environ['PATH']}"}, label="probe")
finally:
lock.release()
time.sleep(3)
assert not late.exists(), "a build descendant kept writing after the build call returned"
def _late_writer(late: Path, *, detach: bool = False, delay: float = 2.0) -> str:
"""Python source for a build descendant that writes ``late`` after ``delay`` seconds, holding
no inherited fd and no pipe (so nothing but a kill stops it); ``detach`` starts its own session."""
detach_code = "os.setsid(); " if detach else "" # windows-footgun: ok - the callers are POSIX-only
return (f"import os, pathlib, time; {detach_code}os.closerange(3, 4096); "
f"time.sleep({delay}); pathlib.Path({str(late)!r}).touch()")
def _node_starting(writer: str, *, then: str) -> list[str]:
"""A stand-in for node: starts ``writer`` (stdio detached), then runs ``then``."""
return [sys.executable, "-c",
"import subprocess, sys, time; d = subprocess.DEVNULL; "
f"subprocess.Popen([sys.executable, '-c', {writer!r}], stdin=d, stdout=d, stderr=d); {then}"]
def _launch_in_custody(repo: Path, tmp_path: Path, command: list[str], *, platform: str | None = None,
**popen) -> subprocess.Popen:
"""Start ``command`` the way the build runner does (contained_command under a held checkout
lock); ``platform`` makes the launcher see another ``sys.platform`` (its no-subreaper path)."""
from hermes_cli.update_custody import contained_command
lock = ul.UpdateLock(path=tmp_path / "marker", install_root=repo)
assert lock.acquire()
try:
with contained_command(command, root=repo) as (argv, custody):
assert argv[3] == "-c", argv
if platform is not None:
argv[4] = f"import sys; sys.platform = {platform!r}\n" + argv[4]
return subprocess.Popen(argv, stdin=subprocess.DEVNULL, **custody, **popen)
finally:
lock.release()
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="POSIX process groups + /proc")
def test_a_group_kill_of_the_caller_stops_the_whole_build(repo, tmp_path):
"""L1: the completion child's Ctrl-C path ``killpg``s its own group and Desktop kills the
backend's group; the build (node and what it starts) must be inside that group. A build
moved into a session of its own outlived the kill and kept writing after the lock was free."""
import signal
late_node, late_child = tmp_path / "late-node", tmp_path / "late-child"
node = _node_starting(_late_writer(late_child), then=f"time.sleep(2); __import__('pathlib').Path({str(late_node)!r}).touch()")
# The launcher leads a fresh group here, as the completion child does for its build.
launcher = _launch_in_custody(repo, tmp_path, node, start_new_session=True)
time.sleep(1)
os.killpg(launcher.pid, signal.SIGKILL) # windows-footgun: ok - Linux-only test
launcher.wait(timeout=10)
time.sleep(3)
assert not late_node.exists(), "node survived a group kill of its caller"
assert not late_child.exists(), "a build descendant survived a group kill of its caller"
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="POSIX sessions + /proc")
@pytest.mark.live_system_guard_bypass # the caller's group kill is the scenario under test
def test_a_group_kill_of_the_caller_keeps_custody_until_a_detached_writer_is_gone(repo, tmp_path):
"""E: the completion child ``killpg``s its own group on interruption; the launcher holding
the lock fd for node's tree was in that group and died with it, so a descendant in a session
of its own kept writing while a contender owned the checkout. A contender must only acquire
once that writer has stopped."""
import contextlib
import signal
beat = tmp_path / "beat"
writer = (f"import os, pathlib, time; os.setsid(); os.closerange(3, 4096); p = pathlib.Path({str(beat)!r})\n" # windows-footgun: ok - Linux-only test
"for i in range(300):\n p.write_text(f'{os.getpid()} {i}'); time.sleep(0.1)")
caller = textwrap.dedent(f"""
import subprocess, sys
sys.path.insert(0, {str(REPO_ROOT)!r})
from pathlib import Path
from hermes_cli import update_lock as ul
from hermes_cli.update_custody import contained_command
repo = Path({str(repo)!r})
lock = ul.UpdateLock(path=Path({str(tmp_path / "marker")!r}), install_root=repo)
assert lock.acquire()
with contained_command({_node_starting(writer, then="time.sleep(60)")!r}, root=repo) as (argv, custody):
subprocess.Popen(argv, stdin=subprocess.DEVNULL, **custody).wait()
""")
owner = subprocess.Popen([sys.executable, "-c", caller], start_new_session=True)
deadline = time.monotonic() + 20
while not beat.exists() and time.monotonic() < deadline:
time.sleep(0.1)
assert beat.exists(), "the detached writer never started"
os.killpg(owner.pid, signal.SIGKILL) # windows-footgun: ok - Linux-only test
owner.wait(timeout=10)
contender = ul.UpdateLock(path=tmp_path / "next-marker", install_root=repo)
deadline = time.monotonic() + 20
while not contender.acquire():
assert time.monotonic() < deadline, "the checkout stayed locked after the group kill"
time.sleep(0.1)
try:
seen = beat.read_text(encoding="utf-8")
time.sleep(1)
assert beat.read_text(encoding="utf-8") == seen, "a detached build writer kept writing under the next owner"
finally:
contender.release()
with contextlib.suppress(ProcessLookupError):
os.kill(int(beat.read_text(encoding="utf-8").split()[0]), signal.SIGKILL) # windows-footgun: ok - Linux-only test
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="POSIX sessions + /proc")
@pytest.mark.live_system_guard_bypass # Ctrl-C of the caller's own group is the scenario under test
@pytest.mark.parametrize(("verbose", "cleanup"), [("1", 2), ("0", 0)], ids=["verbose-slow-cleanup", "captured-instant-exit"])
def test_a_ctrl_c_keeps_custody_until_a_detached_writer_is_gone(repo, tmp_path, verbose, cleanup):
"""R4: on Ctrl-C the build runner's ``subprocess.run`` SIGKILLs its own child, which was the
launcher holding custody; a command still cleaning up from the interrupt then exited and
dropped the last lock fd while a writer in a session of its own went on writing under the
next owner. A contender must only acquire once that writer has stopped."""
import contextlib
import signal
beat, ready = tmp_path / "beat", tmp_path / "ready"
writer = (f"import os, pathlib, time; os.setsid(); os.closerange(3, 4096); p = pathlib.Path({str(beat)!r})\n" # windows-footgun: ok - Linux-only test
"for i in range(300):\n p.write_text(f'{os.getpid()} {i}'); time.sleep(0.1)")
# A build that cleans up for `cleanup` s after SIGINT (2 s: longer than subprocess.run's interrupt wait).
command = [sys.executable, "-c", textwrap.dedent(f"""
import pathlib, signal, subprocess, sys, time
signal.signal(signal.SIGINT, lambda *_: (time.sleep({cleanup}), sys.exit(130)))
subprocess.Popen([sys.executable, '-c', {writer!r}], stdin=subprocess.DEVNULL)
pathlib.Path({str(ready)!r}).touch()
while True: time.sleep(0.1)
""")]
caller = textwrap.dedent(f"""
import os, sys
sys.path.insert(0, {str(REPO_ROOT)!r})
os.environ["HERMES_VERBOSE"] = {verbose!r}
from pathlib import Path
from hermes_cli import update_lock as ul
from hermes_cli.source_build import run_in_custody
repo = Path({str(repo)!r})
assert ul.UpdateLock(path=Path({str(tmp_path / "marker")!r}), install_root=repo).acquire()
run_in_custody(repo, {command!r}, "probe build", stdin=__import__("subprocess").DEVNULL)
""")
owner = subprocess.Popen([sys.executable, "-c", caller], start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
deadline = time.monotonic() + 20
while not (beat.exists() and ready.exists()) and time.monotonic() < deadline:
time.sleep(0.1)
assert beat.exists() and ready.exists(), "the build never started"
os.killpg(owner.pid, signal.SIGINT) # windows-footgun: ok - Linux-only test
owner.wait(timeout=10)
contender = ul.UpdateLock(path=tmp_path / "next-marker", install_root=repo)
deadline = time.monotonic() + 30
while not contender.acquire():
assert time.monotonic() < deadline, "the checkout stayed locked after the interrupted build settled"
time.sleep(0.1)
try:
seen = beat.read_text(encoding="utf-8")
time.sleep(1)
assert beat.read_text(encoding="utf-8") == seen, "a detached build writer kept writing under the next owner"
finally:
contender.release()
with contextlib.suppress(ProcessLookupError, ValueError):
os.kill(int(beat.read_text(encoding="utf-8").split()[0]), signal.SIGKILL) # windows-footgun: ok - Linux-only test
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="POSIX sessions + /proc")
@pytest.mark.parametrize("platform", [None, "darwin"], ids=["subreaper", "ps-walk"])
def test_a_detached_grandchild_outliving_node_dies_before_the_build_returns(repo, tmp_path, platform):
"""N13/L1: a descendant that left node's process group (its own session) and outlives node
is still killed before the build call returns: Linux through the subreaper, elsewhere
through the recorded descendant tree."""
late = tmp_path / "late"
node = _node_starting(_late_writer(late, detach=True), then="time.sleep(1.2)")
launcher = _launch_in_custody(repo, tmp_path, node, platform=platform)
assert launcher.wait(timeout=30) == 0
time.sleep(3)
assert not late.exists(), "a detached build descendant kept writing after the build call returned"
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="POSIX signals + /proc")
def test_a_terminated_launcher_takes_node_and_its_descendants_with_it(repo, tmp_path):
"""L1: SIGTERM to the launcher (a service manager, the caller's polite stop) is forwarded to
node, and what node started is killed too, before the launcher exits."""
import signal
late = tmp_path / "late"
node = _node_starting(_late_writer(late), then="time.sleep(30)")
launcher = _launch_in_custody(repo, tmp_path, node, start_new_session=True)
time.sleep(1)
launcher.send_signal(signal.SIGTERM)
assert launcher.wait(timeout=20) == 128 + signal.SIGTERM
time.sleep(3)
assert not late.exists(), "a build descendant kept writing after its launcher was terminated"
@pytest.mark.skipif(not Path("/proc/self/fd").is_dir(), reason="needs /proc fd listing")
def test_the_desktop_build_runs_in_checkout_custody(repo, tmp_path, monkeypatch):
"""F04: the desktop app build (npm run build / builder) writes the checkout like the other
product builds, so it holds the checkout lock while it runs."""
from hermes_cli import main_desktop
npm = _held_lock_recorder(tmp_path, "npm", "exit 0")
out = tmp_path / "npm.out"
monkeypatch.setenv("HERMES_TEST_LOCK", os.path.realpath(ul.checkout_lock_path(repo)))
monkeypatch.setenv("HERMES_TEST_OUT", str(out))
desktop = repo / "apps" / "desktop"
desktop.mkdir(parents=True)
lock = ul.UpdateLock(path=tmp_path / "marker", install_root=repo)
assert lock.acquire()
try:
main_desktop.build_prepared_desktop(desktop, source_mode=True, npm=str(npm), env=dict(os.environ))
finally:
lock.release()
assert out.read_text(encoding="utf-8-sig").split() == ["yes"], "the desktop build ran without the checkout lock"