Files
odysseus/tests/test_process_lifecycle.py
T
Alexandre Teixeira f9aa2818c4 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
2026-10-02 01:27:08 +01:00

366 lines
15 KiB
Python

"""The generic process lifecycle: identity, probes, escalation, verified death.
These pin the rules every consumer — containment, the PTY shell, the Cookbook
sweep, the browser lifecycle and kill_process_tree — inherits from one place.
"""
import asyncio
import errno
import os
import signal
import subprocess
import sys
import time
import pytest
from core import platform_compat
from src import process_lifecycle, process_ownership
posix_only = pytest.mark.skipif(os.name == "nt", reason="POSIX process groups and signals")
_IGNORE_TERM = (
"import signal, sys, time\n"
"signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
"print('ready', flush=True)\n"
"time.sleep(60)\n"
)
def _spawn(code: str = _IGNORE_TERM) -> subprocess.Popen:
proc = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, start_new_session=True)
assert proc.stdout.readline().strip() == b"ready"
return proc
def _cleanup(proc: subprocess.Popen) -> None:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=5)
proc.stdout.close()
# ── Groups ──────────────────────────────────────────────────────────────────
@posix_only
def test_our_own_group_is_never_present_and_never_signalled(monkeypatch):
own = os.getpgid(0)
sent = []
monkeypatch.setattr(os, "killpg", lambda pgid, sig: sent.append(("group", pgid, sig)))
monkeypatch.setattr(os, "kill", lambda pid, sig: sent.append(("pid", pid, sig)))
assert process_lifecycle.group_present(own) is False
assert process_lifecycle.signal_group(4242, own, signal.SIGTERM) is True
assert sent == [("pid", 4242, signal.SIGTERM)]
@posix_only
def test_a_refused_group_probe_is_a_live_group(monkeypatch):
def refuse(*_args):
raise PermissionError(errno.EPERM, "Operation not permitted")
monkeypatch.setattr(os, "killpg", refuse)
assert process_lifecycle.group_present(987654, own=1) is True
def gone(*_args):
raise ProcessLookupError(errno.ESRCH, "No such process")
monkeypatch.setattr(os, "killpg", gone)
assert process_lifecycle.group_present(987654, own=1) is False
@posix_only
def test_signal_group_reports_when_nothing_was_left_to_signal(monkeypatch):
def gone(*_args):
raise ProcessLookupError(errno.ESRCH, "No such process")
monkeypatch.setattr(os, "killpg", gone)
monkeypatch.setattr(os, "kill", gone)
assert process_lifecycle.signal_group(4242, 4242, signal.SIGTERM, own=1) is False
# ── Escalation ──────────────────────────────────────────────────────────────
def _ladder():
return ((signal.SIGTERM, 0.01), (getattr(signal, "SIGKILL", signal.SIGTERM), 0.01))
def test_escalate_does_not_signal_a_target_already_gone():
sent = []
result = process_lifecycle.escalate(lambda: True, sent.append, steps=_ladder())
assert result.dead is True and result.escalated is False and sent == []
def test_escalate_terms_then_kills_and_reports_the_observed_outcome():
sent = []
result = process_lifecycle.escalate(lambda: False, sent.append, steps=_ladder(), poll_s=0.001)
assert sent == [step[0] for step in _ladder()]
assert result.dead is False and result.escalated is True
def test_escalate_stops_when_term_is_enough():
sent = []
result = process_lifecycle.escalate(lambda: bool(sent), sent.append, steps=_ladder())
assert sent == [signal.SIGTERM]
assert result.dead is True and result.escalated is False
def test_escalate_reverifies_before_kill_and_refuses_on_a_lost_identity():
sent = []
result = process_lifecycle.escalate(
lambda: False, sent.append, steps=_ladder(), poll_s=0.001,
before_step=lambda sig: "foreign",
)
assert sent == [signal.SIGTERM]
assert result.refusal == "foreign" and result.dead is False
def test_escalate_stops_the_ladder_when_nothing_is_left_to_signal():
sent = []
def send(sig):
sent.append(sig)
return False
result = process_lifecycle.escalate(lambda: False, send, steps=_ladder())
assert sent == [signal.SIGTERM] and result.dead is False
async def test_escalate_async_signals_first_and_reaps_inside_the_window():
sent, waited = [], []
state = {"gone": False}
async def wait():
waited.append(True)
state["gone"] = True
result = await process_lifecycle.escalate_async(
lambda: state["gone"], sent.append, steps=_ladder(), wait=wait,
)
# No precheck: an awaited leader is reaped only after the first signal.
assert sent == [signal.SIGTERM] and waited == [True]
assert result.dead is True and result.escalated is False
async def test_escalate_async_gives_the_reap_a_floor_even_at_zero_grace():
seen = {}
async def wait():
seen["ran"] = True
await process_lifecycle.escalate_async(
lambda: False, lambda sig: None, steps=((signal.SIGTERM, 0.0),), wait=wait,
)
assert seen == {"ran": True}
# ── Identity ────────────────────────────────────────────────────────────────
def test_identity_from_record_needs_a_pid():
assert process_lifecycle.ProcessIdentity.from_record({}) is None
assert process_lifecycle.ProcessIdentity.from_record({"pid": "x"}) is None
ident = process_lifecycle.ProcessIdentity.from_record(
{"supervisor_pid": 42, "supervisor_token": "t", "pgid": "7"},
pid_key="supervisor_pid", token_key="supervisor_token",
)
assert ident == process_lifecycle.ProcessIdentity(pid=42, start_token="t", pgid=7)
def test_a_pid_without_a_token_is_never_owned_and_never_exited(monkeypatch):
monkeypatch.setattr(process_ownership, "start_token", lambda pid: "live")
ident = process_lifecycle.ProcessIdentity(pid=4242, start_token=None)
assert ident.verdict() == process_ownership.UNVERIFIABLE
assert ident.owned() is False and ident.exited() is False
@pytest.mark.parametrize("current,exited", [(None, True), ("other", True), ("mine", False)])
def test_exited_means_gone_or_recycled(monkeypatch, current, exited):
monkeypatch.setattr(process_ownership, "start_token", lambda pid: current)
monkeypatch.setattr(process_lifecycle, "is_zombie", lambda pid: False)
ident = process_lifecycle.ProcessIdentity(pid=4242, start_token="mine")
assert ident.exited() is exited
@pytest.mark.skipif(not platform_compat.has_procfs(), reason="zombie state needs procfs")
def test_an_unreaped_zombie_has_exited():
proc = subprocess.Popen([sys.executable, "-c", "pass"])
ident = process_lifecycle.ProcessIdentity.capture(proc.pid)
try:
for _ in range(250):
if process_lifecycle.is_zombie(proc.pid):
break
time.sleep(0.02)
assert ident.verdict() == process_ownership.OWNED # still in its slot
assert ident.exited() is True
finally:
proc.wait(timeout=5)
# ── Snapshot identities ─────────────────────────────────────────────────────
def test_terminate_identities_never_signals_foreign_or_unverifiable(monkeypatch):
tokens = {1: "now-someone-else", 2: None}
def start_token(pid):
if pid == 3:
raise process_ownership.InspectionUnavailable("stat")
return tokens.get(pid)
monkeypatch.setattr(process_ownership, "start_token", start_token)
monkeypatch.setattr(os, "kill", lambda *a: pytest.fail("signalled an unowned pid"))
sweep = process_lifecycle.terminate_identities(
[process_lifecycle.ProcessIdentity(1, "mine"),
process_lifecycle.ProcessIdentity(2, "mine"),
process_lifecycle.ProcessIdentity(3, "mine")],
steps=_ladder(),
)
assert sweep.killed == () and sweep.survivors == ()
assert sweep.unverified == (3,) and sweep.dead is False
def test_terminate_identities_kills_in_order_and_reverifies_each_signal(monkeypatch):
alive = {10: "a", 11: "b", 12: "c"}
sent = []
monkeypatch.setattr(process_ownership, "start_token", lambda pid: alive.get(pid))
monkeypatch.setattr(process_lifecycle, "is_zombie", lambda pid: False)
def kill(pid, sig):
sent.append((pid, sig))
alive.pop(pid, None)
monkeypatch.setattr(os, "kill", kill)
sweep = process_lifecycle.terminate_identities(
[process_lifecycle.ProcessIdentity(pid, token) for pid, token in list(alive.items())],
steps=((signal.SIGTERM, 0.05),),
)
assert [pid for pid, _ in sent] == [10, 11, 12]
assert sweep.killed == (10, 11, 12) and sweep.dead
@posix_only
@pytest.mark.skipif(not platform_compat.has_procfs(), reason="real identity needs procfs")
def test_terminate_identities_escalates_past_an_ignored_sigterm():
proc = _spawn()
try:
ident = process_lifecycle.ProcessIdentity.capture(proc.pid)
sweep = process_lifecycle.terminate_identities(
[ident], steps=process_lifecycle.term_kill_steps(0.2))
assert sweep.killed == (proc.pid,) and sweep.survivors == ()
assert proc.wait(timeout=5) == -signal.SIGKILL
finally:
_cleanup(proc)
# ── terminate_tree / kill_process_tree ──────────────────────────────────────
@posix_only
@pytest.mark.skipif(not platform_compat.has_procfs(), reason="real identity needs procfs")
def test_terminate_tree_requires_a_matching_identity():
proc = _spawn()
try:
refused = process_lifecycle.terminate_tree(
proc.pid, pgid=proc.pid, start_token="procfs:not-this-process",
require_identity=True, grace_s=0.1)
assert refused.dead is False and refused.ownership == process_ownership.FOREIGN
assert refused.survivors == ()
assert proc.poll() is None
token = process_ownership.start_token(proc.pid)
outcome = process_lifecycle.terminate_tree(
proc.pid, pgid=proc.pid, start_token=token, require_identity=True, grace_s=0.2)
assert outcome.dead is True and outcome.escalated is True
finally:
_cleanup(proc)
@posix_only
def test_terminate_tree_keeps_a_leaderless_group_visible(monkeypatch):
monkeypatch.setattr(process_ownership, "start_token", lambda pid: None)
monkeypatch.setattr(process_lifecycle, "group_present", lambda pgid, **kw: True)
monkeypatch.setattr(process_lifecycle, "signal_group",
lambda *a, **kw: pytest.fail("signalled an unproven group"))
outcome = process_lifecycle.terminate_tree(4242, pgid=4242, start_token="t",
require_identity=True)
assert outcome.dead is False and outcome.survivors == (4242,)
assert outcome.ownership == process_ownership.GONE
@posix_only
def test_kill_process_tree_writes_no_containment_record(monkeypatch):
from src import containment
monkeypatch.setattr(containment, "_update_record", lambda *a, **k: pytest.fail("grant store touched"))
proc = _spawn("import time\nprint('ready', flush=True)\ntime.sleep(60)\n")
try:
outcome = platform_compat.kill_process_tree(proc.pid)
assert outcome.dead is True
assert isinstance(outcome, containment.ReleaseOutcome)
finally:
_cleanup(proc)
def test_termination_outcome_shape_is_the_teardown_block():
outcome = process_lifecycle.TerminationOutcome(dead=False, escalated=True, survivors=(1,),
mechanism="process_group", ownership="owned")
assert outcome.to_dict() == {"dead": False, "escalated": True, "survivors": [1],
"mechanism": "process_group", "ownership": "owned"}
# ── Identity-bound observation ──────────────────────────────────────────────
def _tokens(monkeypatch, sequence):
calls = iter(sequence)
def start_token(pid):
value = next(calls)
if isinstance(value, Exception):
raise value
return value
monkeypatch.setattr(process_ownership, "start_token", start_token)
def test_observe_binds_facts_to_the_token_read_around_them(monkeypatch):
_tokens(monkeypatch, ["t1", "t1"])
seen = process_lifecycle.observe(42, lambda pid: "facts")
assert seen.identity == process_lifecycle.ProcessIdentity(42, "t1") and seen.facts == "facts"
def test_observe_drops_facts_read_across_a_pid_reuse(monkeypatch):
_tokens(monkeypatch, ["old", "new"])
assert process_lifecycle.observe(42, lambda pid: "whose facts?") is None
def test_observe_of_a_gone_pid_reads_nothing(monkeypatch):
_tokens(monkeypatch, [None])
assert process_lifecycle.observe(42, lambda pid: pytest.fail("read a gone pid")) is None
@pytest.mark.parametrize("sequence", [
[process_ownership.InspectionUnavailable("stat")],
["t1", process_ownership.InspectionUnavailable("stat")],
])
def test_observe_keeps_unidentifiable_facts_without_a_token(monkeypatch, sequence):
_tokens(monkeypatch, sequence)
seen = process_lifecycle.observe(42, lambda pid: "facts")
assert seen.identity.start_token is None and seen.facts == "facts"
assert seen.identity.verdict() == process_ownership.UNVERIFIABLE
def test_bind_descendants_drops_a_pid_reparented_between_snapshots(monkeypatch):
Info = process_ownership.ProcessInfo
tables = iter([
{10: Info(10, 1, "shell"), 11: Info(11, 10, "server")},
{10: Info(10, 1, "shell"), 11: Info(11, 1, "stranger")},
])
monkeypatch.setattr(process_ownership, "process_table", lambda: next(tables))
monkeypatch.setattr(process_ownership, "start_token", lambda pid: f"t{pid}")
bound = process_lifecycle.bind_descendants([10])
assert [seen.identity.pid for seen in bound] == [10]
def test_bind_descendants_drops_a_pid_whose_token_changed(monkeypatch):
Info = process_ownership.ProcessInfo
table = {10: Info(10, 1, "shell"), 11: Info(11, 10, "server")}
monkeypatch.setattr(process_ownership, "process_table", lambda: dict(table))
monkeypatch.setattr(process_ownership, "start_token", lambda pid: f"t{pid}")
monkeypatch.setattr(process_ownership, "verify",
lambda pid, token: process_ownership.FOREIGN if pid == 11 else process_ownership.OWNED)
bound = process_lifecycle.bind_descendants([10], exclude={99})
assert [(seen.identity.pid, seen.facts.command) for seen in bound] == [(10, "shell")]