Files
hermes-agent/tools/process_registry_termination.py
2026-10-08 17:22:54 +02:00

192 lines
8.6 KiB
Python

"""Process-tree termination and post-kill survivor verification for ProcessRegistry."""
import logging
import os
import signal
import subprocess
import time
from contextlib import suppress
from typing import TYPE_CHECKING, List, Optional
from hermes_cli._subprocess_compat import windows_hide_flags
if TYPE_CHECKING:
from tools.process_registry import ProcessSession
logger = logging.getLogger("tools.process_registry")
class ProcessTerminationMixin:
"""The subclass supplies ``_host_pid_is_ours``, ``_is_host_pid_alive``,
``_detached_host_fate`` and ``_daemon_term_grace_seconds``. See ProcessRegistry."""
@staticmethod
def _proc_alive(proc) -> bool:
"""True if a psutil.Process is running and not a zombie (already dead, just unreaped)."""
try:
import psutil
return proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE
except Exception:
return False
@classmethod
def _terminate_host_pid(cls, pid: int, expected_start: Optional[int] = None) -> None:
"""Terminate a host-visible PID and its descendants.
``expected_start`` (kernel start time at spawn) is re-validated first: a mismatch
or dead PID means the number was recycled onto a stranger and we refuse to touch
it — a leaked orphan beats tree-killing someone's browser. POSIX: snapshot descendants,
SIGTERM the parent alone so it can perform an orderly shutdown, then clean up snapshot
descendants that survive its grace window. Survivors are SIGKILLed after a second
``terminal.daemon_term_grace_seconds`` window. Windows:
``taskkill /T /F`` (psutil's stale PPID links miss orphans there); ``os.kill``
is the fallback."""
from tools.process_registry import _IS_WINDOWS
if expected_start is not None and not cls._host_pid_is_ours(pid, expected_start):
logger.warning(
"Refusing to terminate host pid %d: start-time mismatch — "
"PID was recycled onto an unrelated process.", pid)
return
def _sigterm_quietly():
with suppress(OSError, ProcessLookupError, PermissionError):
os.kill(pid, signal.SIGTERM)
if _IS_WINDOWS:
try:
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True,
encoding='utf-8', errors='replace', timeout=10, creationflags=windows_hide_flags(),
stdin=subprocess.DEVNULL)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
_sigterm_quietly()
return
import psutil
gone = (psutil.NoSuchProcess, psutil.AccessDenied, OSError)
try:
parent = psutil.Process(pid)
except psutil.NoSuchProcess:
return
except (OSError, PermissionError):
_sigterm_quietly()
return
# Snapshot before signalling: once the parent exits, psutil can no longer
# reliably find children that it failed to reap.
try:
descendants = parent.children(recursive=True)
except gone:
descendants = []
# Let self-managing parents (notably Chromium/Electron) shut down their
# tree before touching children. Killing their zygotes first can turn a
# graceful browser shutdown into a crash dump.
with suppress(gone):
parent.terminate()
grace = cls._daemon_term_grace_seconds()
def _wait_for_exit(targets) -> None:
if grace <= 0:
return
deadline = time.monotonic() + grace
while time.monotonic() < deadline and any(cls._proc_alive(p) for p in targets):
time.sleep(0.05)
# Preserve descendants during the parent's configured shutdown window.
_wait_for_exit([parent])
# The snapshot is an anti-orphan guarantee: only descendants still alive
# after the parent had its chance are asked to terminate themselves.
remaining = descendants if grace <= 0 else [
proc for proc in descendants if cls._proc_alive(proc)
]
for proc in remaining:
with suppress(gone):
proc.terminate()
# Preserve the existing SIGKILL escalation semantics for every owned
# process that remains after its SIGTERM grace window. The parent is
# included in case it ignored the first signal.
targets = [parent, *remaining]
# Escalate to SIGKILL for anything that ignored SIGTERM within the grace window.
# ``psutil.wait_procs``' gone/alive partition is deliberately NOT trusted: it
# reaps via ``Process.wait()`` and mis-partitions across zombie transitions in a
# parent/child tree, leaving survivors un-killed. Re-probing every target is
# deterministic.
if grace <= 0:
return
_wait_for_exit(targets)
# A parent that ignored SIGTERM (the interactive ``bash -lic`` wrapper does) keeps
# running its script through both grace windows and can spawn children the first
# snapshot never saw. Re-snapshot while it is still alive: once it is SIGKILLed
# they reparent to init and nothing can find them again.
with suppress(gone):
if cls._proc_alive(parent):
known = {proc.pid for proc in targets}
targets.extend(p for p in parent.children(recursive=True) if p.pid not in known)
for proc in targets:
with suppress(gone):
if cls._proc_alive(proc):
proc.kill() # SIGKILL on POSIX
logger.info("Escalated to SIGKILL for pid %d (ignored SIGTERM within %.1fs grace)", proc.pid, grace)
@staticmethod
def _live_descendants(pid: int) -> list[int]:
"""PIDs of living non-zombie descendants of host PID ``pid`` (best-effort)."""
try:
import psutil
children = psutil.Process(pid).children(recursive=True)
except Exception:
logger.debug("Could not list descendants of pid %s", pid, exc_info=True)
return []
return [c.pid for c in children if ProcessTerminationMixin._proc_alive(c)]
# SIGKILL / taskkill are asynchronous: the kernel needs a scheduling tick to
# tear the process down and the parent must reap it before poll()/isalive()
# stop saying "alive". Verifying survivors in that window flagged every
# escalated kill as incomplete.
_KILL_SETTLE_SECONDS = 1.0
def _post_kill_survivors(self, session: "ProcessSession") -> list[int]:
"""Host PIDs still alive once the kill signals have had time to land (#115490).
Fail-closed: anything unverifiable counts as a survivor, so a kill
that leaves a live tree can never write a killed receipt. Sandbox
(env) sessions have no host-visible tree and are unverifiable by
design — they return no survivors, preserving existing behavior."""
deadline = time.monotonic() + self._KILL_SETTLE_SECONDS
while True:
survivors = self._probe_survivors(session)
if not survivors or time.monotonic() >= deadline:
return survivors
time.sleep(0.05)
def _probe_survivors(self, session: "ProcessSession") -> list[int]:
survivors: list[int] = []
proc = getattr(session, "process", None)
if proc is not None:
try:
root_alive = proc.poll() is None
except Exception:
root_alive = True
if root_alive:
survivors.append(getattr(proc, "pid", None) or session.pid)
pty = getattr(session, "_pty", None)
if pty is not None:
try:
pty_alive = bool(pty.isalive())
except Exception:
pty_alive = self._is_host_pid_alive(session.pid)
if pty_alive:
survivors.append(session.pid)
if session.pid_scope == "host" and session.pid:
if self._detached_host_fate(session.pid, session.host_start_time) == "running":
if session.pid not in survivors:
survivors.append(session.pid)
survivors.extend(
pid for pid in self._live_descendants(session.pid)
if pid not in survivors)
# A dead/recycled root has no PID-scope descendants left to find:
# reparented orphans are outside PID scope (systemd scope stop,
# issued before this check, covers the cgroup case).
return [pid for pid in survivors if pid]