refactor(runtime): centralize verified process lifecycle

Extract the generic process lifecycle layer (src/process_lifecycle.py)
shared by runtime-owned subprocesses: process identity (pid + boot-bound
start token), identity-bound observation, group and pidfd probes, the
TERM -> verify -> KILL -> verify escalation with re-gating before
escalation, identity-scoped sweeps, and the termination receipt.

Containment, the PTY shell, the Cookbook survivor sweep, the browser
lifecycle, web_tools browser cleanup, kill_process_tree and the startup
reaper consume it while keeping their own ownership semantics.

Safety corrections:
- browser membership and identity are bound in one snapshot; no identity
  is recaptured after membership is decided
- web_tools legacy pid-file and profile-match kills signal only verified
  identities; browser CLI groups only while their spawn identity verifies
- Cookbook and legacy-tmux descendant capture bind membership to identity
- PTY teardown never signals the server's own process group
- unverifiable processes are reported, never signalled
This commit is contained in:
Alexandre Teixeira
2026-10-02 01:27:08 +01:00
parent 7aa891e5e6
commit f9aa2818c4
14 changed files with 1692 additions and 408 deletions
+32 -60
View File
@@ -26,6 +26,7 @@ from src.host_docker_access import (
)
from src.optional_deps import prepare_optional_dependency_import
from src.auth_helpers import _auth_disabled
from src import process_lifecycle
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
# on Windows, so importing them unconditionally crashed app startup there
@@ -683,15 +684,15 @@ def _session_pgid(pid: int) -> int | None:
"""Process-group id of the session ``pid`` leads, or None if unavailable.
Read this *before* the leader is reaped: once it is, ``getpgid`` fails and
the group id can no longer be recovered from the pid.
the group id can no longer be recovered from the pid. If the group is the
server's own — ``setsid`` did not take effect — there is no session group
to signal, and None makes teardown reach the child alone instead of the
whole server.
"""
getpgid = getattr(os, "getpgid", None)
if getpgid is None: # no process groups (native Windows)
return None
try:
return getpgid(pid)
except OSError:
pgid = process_lifecycle.pgid_of(pid)
if pgid is None or pgid == process_lifecycle.own_pgid():
return None
return pgid
def _signal_session(pgid: int | None, pid: int, sig: int) -> bool:
@@ -702,20 +703,7 @@ def _signal_session(pgid: int | None, pid: int, sig: int) -> bool:
matters: if ``setsid`` did not take effect, or the platform has no process
groups, teardown must still reach the child rather than do nothing.
"""
killpg = getattr(os, "killpg", None)
if pgid is not None and killpg is not None:
try:
killpg(pgid, sig)
return True
except ProcessLookupError:
return False
except OSError:
pass # group signalling refused — fall through to the single pid
try:
os.kill(pid, sig)
return True
except OSError:
return False
return process_lifecycle.signal_group(pid, pgid, sig)
def _session_alive(pgid: int | None, pid: int) -> bool:
@@ -723,43 +711,16 @@ def _session_alive(pgid: int | None, pid: int) -> bool:
An unreaped zombie is still signallable, so a True here can also mean the
leader has exited but not yet been collected. Without a group id this can
only speak for the child itself, not for anything it spawned.
only speak for the child itself, not for anything it spawned. Only ESRCH
proves a group is gone; EPERM is a live group we may not signal, and
reporting a surviving session as contained is the one outcome teardown
must never produce.
"""
killpg = getattr(os, "killpg", None)
if pgid is not None and killpg is not None:
try:
killpg(pgid, 0)
except ProcessLookupError:
return False # ESRCH — no member of the group is left
except OSError:
# Anything else (EPERM when the group holds a process we may not
# signal, EINVAL) answers the probe without proving the group is
# gone. Only ESRCH does that, so treat the rest as still running:
# reporting a surviving session as contained is the one outcome
# teardown must never produce.
return True
return True
if pgid is not None:
return process_lifecycle.group_present(pgid)
return pid_alive(pid)
async def _await_session_exit(proc, pgid: int | None, pid: int) -> bool:
"""Wait up to the grace period for the leader and its group to go away."""
loop = asyncio.get_running_loop()
deadline = loop.time() + PTY_KILL_GRACE
while True:
remaining = deadline - loop.time()
if proc.returncode is None and remaining > 0:
# Reap the leader, otherwise its own zombie keeps the group alive
# and the liveness probe below can never come back clean.
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(proc.wait(), remaining)
if not _session_alive(pgid, pid):
return True
if loop.time() >= deadline:
return False
await asyncio.sleep(PTY_KILL_POLL_INTERVAL)
async def _terminate_pty_session(proc) -> bool:
"""Kill the PTY child and every process in the session it leads.
@@ -770,18 +731,29 @@ async def _terminate_pty_session(proc) -> bool:
command as terminated. Signal the group instead, escalate to SIGKILL if it
outlives the grace period, and return whether the session is actually gone
so the caller can say so rather than assume it.
The ladder itself is :func:`src.process_lifecycle.escalate_async`; the
leader is reaped through ``proc.wait()`` inside each window, otherwise its
own zombie keeps the group alive and the probe can never come back clean.
"""
pid = getattr(proc, "pid", None)
if pid is None:
return True
pgid = _session_pgid(pid)
for sig in PTY_KILL_ESCALATION:
if not _signal_session(pgid, pid, sig):
break # nothing left to signal
if await _await_session_exit(proc, pgid, pid):
return True
return not _session_alive(pgid, pid)
async def _reap_leader():
if proc.returncode is None:
await proc.wait()
result = await process_lifecycle.escalate_async(
lambda: not _session_alive(pgid, pid),
lambda sig: _signal_session(pgid, pid, sig),
steps=tuple((sig, PTY_KILL_GRACE) for sig in PTY_KILL_ESCALATION),
wait=_reap_leader,
poll_s=PTY_KILL_POLL_INTERVAL,
wait_floor_s=0.0,
)
return result.dead
async def _terminate_pty_session_quietly(proc) -> None: