okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petfeat: background cells, jobs page, and session list fixesf65a648

main
12.1 KiB342 linesraw
1"""Owned process groups: identify one, probe it, end it, report what happened.
2
3The kernel's job supervision and the supervisor's checked signal helper both run
4this ladder, so the two layers agree on what "terminated" means. A group is live
5only while it holds a member we can still signal; a reaped leader or a group of
6non-running (zombie) members is not work.
7"""
8
9from __future__ import annotations
10
11from collections.abc import Callable, Sequence
12from dataclasses import dataclass
13from typing import Any
14import asyncio
15import ctypes
16import os
17import signal
18import struct
19import subprocess
20import sys
21
22TERM_GRACE = 0.25  # seconds a group gets to honour TERM
23KILL_GRACE = 2.0  # seconds a group gets to die after KILL
24PROBE_INTERVAL = 0.02
25LIBC = ctypes.CDLL(None)
26
27
28def reap_stopped_safely(loop: asyncio.AbstractEventLoop) -> None:
29    """Let `loop` run children that get paused with SIGSTOP.
30
31    Python 3.14's threaded child watcher waits with waitid(WEXITED | WNOWAIT),
32    which macOS also answers for a stopped child, and then reaps with a blocking
33    waitpid on the event loop, which can freeze the kernel on a stopped child.
34    The replacement reaps in its thread with a waitpid that ignores stops, as
35    3.13 did. Linux uses pidfds, which only wake on exit, and needs nothing.
36    """
37    threaded: Any = getattr(
38        getattr(asyncio, "unix_events", None), "_ThreadedChildWatcher", None
39    )
40    if (
41        sys.platform != "darwin"
42        or threaded is None
43        or not isinstance(getattr(loop, "_watcher", None), threaded)
44    ):
45        return
46
47    class ExitWatcher(threaded):
48        def _do_waitpid(
49            self,
50            loop: asyncio.AbstractEventLoop,
51            expected_pid: int,
52            callback: Callable[..., object],
53            args: tuple[object, ...],
54        ) -> None:
55            try:
56                returncode = os.waitstatus_to_exitcode(os.waitpid(expected_pid, 0)[1])
57            except ChildProcessError:
58                returncode = 255
59            try:
60                loop.call_soon_threadsafe(callback, expected_pid, returncode, *args)
61            except RuntimeError:  # the loop closed first
62                pass
63            self._threads.pop(expected_pid, None)
64
65    setattr(loop, "_watcher", ExitWatcher())
66
67
68@dataclass(frozen=True)
69class Group:
70    """A target to end: a whole process group, or one process that leads none.
71
72    `leader` identifies the leader by its start time, so a reused id is refused.
73    """
74
75    pgid: int
76    leader: str | None = None
77    whole: bool = True
78
79
80@dataclass(frozen=True)
81class Termination:
82    """What one termination attempt established about one process group."""
83
84    pgid: int | None
85    signals: tuple[str, ...]
86    gone: bool
87    failures: tuple[str, ...] = ()
88    note: str = ""
89
90    def report(self) -> str:
91        where = (
92            "no process group" if self.pgid is None else f"process group {self.pgid}"
93        )
94        outcome = "terminated" if self.gone else "SURVIVED"
95        sent = "+".join(self.signals) if self.signals else "no signal"
96        detail = (
97            "; ".join((*self.failures, self.note))
98            if self.note
99            else "; ".join(self.failures)
100        )
101        return f"{where} {outcome} after {sent}" + (f": {detail}" if detail else "")
102
103    def as_json(self) -> dict[str, object]:
104        return {
105            "pgid": self.pgid,
106            "signals": list(self.signals),
107            "gone": self.gone,
108            "failures": list(self.failures),
109            "note": self.note,
110        }
111
112
113def start_token(stat: bytes) -> str | None:
114    """Start time of one /proc/<pid>/stat line (field 22, after the command name)."""
115    try:
116        return stat.rsplit(b")", 1)[1].split()[19].decode()
117    except (IndexError, UnicodeDecodeError):
118        return None
119
120
121def leader_token(pid: int) -> str | None:
122    """Identity token for a live pid: its start time, from /proc or, on
123    Darwin, its process table entry. None where neither can tell."""
124    try:
125        with open(f"/proc/{pid}/stat", "rb") as stat:
126            return start_token(stat.read())
127    except OSError:
128        return darwin_start(pid) if sys.platform == "darwin" else None
129
130
131def darwin_start(pid: int) -> str | None:
132    """A Darwin process's start time: the timeval that opens its kinfo_proc
133    (sysctl CTL_KERN, KERN_PROC, KERN_PROC_PID). None for a pid not in use."""
134    mib = (ctypes.c_int * 4)(1, 14, 1, pid)
135    buffer = ctypes.create_string_buffer(1024)
136    size = ctypes.c_size_t(len(buffer))
137    if LIBC.sysctl(mib, 4, buffer, ctypes.byref(size), None, 0) != 0 or size.value < 12:
138        return None
139    seconds, micros = struct.unpack_from("=qi", buffer.raw)
140    return f"{seconds}.{micros:06d}"
141
142
143def table() -> list[tuple[int, int, int, bool]] | None:
144    """Every process as (pid, ppid, pgid, zombie); None where the view is
145    unavailable or incomplete."""
146    try:
147        entries = os.listdir("/proc")
148    except OSError:
149        if sys.platform != "darwin":
150            return None
151        # Darwin has no /proc. Ask its process table; EPERM alone never means dead.
152        try:
153            result = subprocess.run(
154                [
155                    "/bin/ps",
156                    "-A",
157                    "-o",
158                    "pid=",
159                    "-o",
160                    "ppid=",
161                    "-o",
162                    "pgid=",
163                    "-o",
164                    "stat=",
165                ],
166                capture_output=True,
167                text=True,
168                timeout=0.5,
169                check=True,
170            )
171            rows = [line.split() for line in result.stdout.splitlines()]
172            return [
173                (int(pid), int(ppid), int(pgid), state.startswith("Z"))
174                for pid, ppid, pgid, state in rows
175            ]
176        except (OSError, subprocess.SubprocessError, ValueError):
177            return None
178    processes = []
179    for entry in entries:
180        if not entry.isdigit():
181            continue
182        try:
183            with open(f"/proc/{entry}/stat", "rb") as stat:
184                fields = stat.read().rsplit(b")", 1)[1].split()
185            processes.append(
186                (int(entry), int(fields[1]), int(fields[2]), fields[0] == b"Z")
187            )
188        except FileNotFoundError:
189            continue  # process exited while enumerating
190        except (OSError, IndexError, ValueError):
191            return None  # an incomplete view cannot prove the group empty
192    return processes
193
194
195def live_members(pgid: int) -> list[int] | None:
196    """Pids in a group that are not zombies; None where the process table is unavailable."""
197    processes = table()
198    if processes is None:
199        return None
200    return [pid for pid, _, group, zombie in processes if group == pgid and not zombie]
201
202
203def adopted(group: Group) -> set[int]:
204    """Members of the group that a process which moved out of it still
205    parents, with their descendants in it.
206
207    ssh's ControlPersist master forks its ProxyCommand, then daemonizes out of
208    the job's group: the proxy stays behind, and ending it ends the master and
209    every later ssh that would have ridden it. Such a parent started after the
210    group's leader, from inside the job; init or a subreaper that adopted an
211    orphan was running before it, so orphans are never among these.
212    """
213    if not group.whole or group.leader is None:
214        return set()
215    processes = table()
216    if processes is None:
217        return set()
218    groups = {pid: pgid for pid, _, pgid, _ in processes}
219    members = {
220        pid: ppid
221        for pid, ppid, pgid, zombie in processes
222        if pgid == group.pgid and not zombie
223    }
224    begun = float(group.leader)
225    kept = set()
226    for pid, ppid in members.items():
227        if groups.get(ppid, group.pgid) == group.pgid:
228            continue
229        started = leader_token(ppid)
230        if started is not None and float(started) >= begun:
231            kept.add(pid)
232    while descendants := {
233        pid for pid, ppid in members.items() if ppid in kept and pid not in kept
234    }:
235        kept |= descendants
236    return kept
237
238
239def deliver(group: Group, sig: int) -> None:
240    """Signal the target: its group, or the single process when it leads none."""
241    (os.killpg if group.whole else os.kill)(group.pgid, sig)
242
243
244def alive(group: Group) -> bool:
245    """True while the target exists and still looks like the one we created."""
246    try:
247        deliver(group, 0)
248    except ProcessLookupError:
249        return False
250    except PermissionError:
251        return True  # existence is known; inability to signal is a cleanup failure
252    if group.leader is None:
253        return True
254    token = leader_token(group.pgid)
255    return token is None or token == group.leader
256
257
258def current(group: Group) -> bool:
259    """alive/1 refined by membership: zombies awaiting reaping are not work."""
260    if not alive(group):
261        return False
262    if not group.whole:
263        return True
264    members = live_members(group.pgid)
265    return True if members is None else bool(members)
266
267
268def send_signal(group: Group, sig: int) -> str | None:
269    """Send one signal to a target; returns the failure text when it did not land."""
270    try:
271        deliver(group, sig)
272        return None
273    except ProcessLookupError:
274        return None  # already gone; the probe decides
275    except OSError as error:
276        return f"{signal.Signals(sig).name} to {group.pgid}: {error.strerror or error}"
277
278
279async def settled(live: dict[int, Group], window: float) -> dict[int, Group]:
280    """The groups still alive after one shared wait, so batches share a deadline."""
281    deadline = asyncio.get_running_loop().time() + window
282    while True:
283        remaining = {pgid: group for pgid, group in live.items() if alive(group)}
284        if not remaining or asyncio.get_running_loop().time() >= deadline:
285            return remaining
286        await asyncio.sleep(PROBE_INTERVAL)
287
288
289async def terminate(
290    groups: Sequence[Group], term: float = TERM_GRACE, kill: float = KILL_GRACE
291) -> list[Termination]:
292    """End every group: TERM, then KILL, one shared deadline per step."""
293    live = {group.pgid: group for group in groups if alive(group)}
294    sent: dict[int, list[str]] = {pgid: [] for pgid in live}
295    failures: dict[int, list[str]] = {pgid: [] for pgid in live}
296    for sig, window in ((signal.SIGTERM, term), (signal.SIGKILL, kill)):
297        for pgid, group in live.items():
298            failure = send_signal(group, sig)
299            sent[pgid].append(signal.Signals(sig).name)
300            if failure is not None:
301                failures[pgid].append(failure)
302        live = await settled(live, window)
303        if not live:
304            break
305    endings = []
306    for group in groups:
307        pgid, signals = group.pgid, sent.get(group.pgid, [])
308        surviving, note = pgid in live, ""
309        if surviving and not current(group):
310            surviving, note = False, "only non-running members remained"
311        endings.append(
312            Termination(
313                pgid, tuple(signals), not surviving, tuple(failures.get(pgid, [])), note
314            )
315        )
316    return endings
317
318
319async def end(group: Group, leave_adopted: bool = False) -> Termination:
320    """End a job's group. Once its command has exited on its own
321    (`leave_adopted`), members a process that left the group still parents
322    are that process's (`adopted`) and stay; the rest are ended one by one."""
323    if not (leave_adopted and alive(group)):
324        return (await terminate([group]))[0]
325    signals: list[str] = []
326    failures: list[str] = []
327    rounds = 0
328    while True:
329        kept = adopted(group)
330        if not kept:
331            return (await terminate([group]))[0]
332        rest = [pid for pid in live_members(group.pgid) or [] if pid not in kept]
333        if not rest or rounds == 3:  # a member may fork while the others end
334            note = f"kept {len(kept)} that a process which left the group parents"
335            return Termination(
336                group.pgid, tuple(signals), not rest, tuple(failures), note
337            )
338        rounds += 1
339        targets = [Group(pid, leader_token(pid), whole=False) for pid in rest]
340        for ending in await terminate(targets):
341            signals += [name for name in ending.signals if name not in signals]
342            failures += ending.failures