okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
f65a648
main
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 31Python 3.14's threaded child watcher waits with waitid(WEXITED | WNOWAIT), 32which macOS also answers for a stopped child, and then reaps with a blocking 33waitpid on the event loop, which can freeze the kernel on a stopped child. 34The replacement reaps in its thread with a waitpid that ignores stops, as 353.13 did. Linux uses pidfds, which only wake on exit, and needs nothing. 36""" 37threaded :Any = getattr ( 38getattr (asyncio ,"unix_events" ,None ),"_ThreadedChildWatcher" ,None 39 ) 40if ( 41sys .platform != "darwin" 42or threaded is None 43or not isinstance (getattr (loop ,"_watcher" ,None ),threaded ) 44 ): 45return 46 47class ExitWatcher (threaded ): 48def _do_waitpid ( 49self , 50loop :asyncio .AbstractEventLoop , 51expected_pid :int , 52callback :Callable [...,object ], 53args :tuple [object , ...], 54 )-> None : 55try : 56returncode = os .waitstatus_to_exitcode (os .waitpid (expected_pid ,0 )[1 ]) 57except ChildProcessError : 58returncode = 255 59try : 60loop .call_soon_threadsafe (callback ,expected_pid ,returncode ,* args ) 61except RuntimeError :# the loop closed first 62pass 63self ._threads .pop (expected_pid ,None ) 64 65setattr (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 75pgid :int 76leader :str | None = None 77whole :bool = True 78 79 80@dataclass( frozen = True ) 81class Termination : 82"""What one termination attempt established about one process group.""" 83 84pgid :int | None 85signals :tuple [str , ...] 86gone :bool 87failures :tuple [str , ...]= () 88note :str = "" 89 90def report (self )-> str : 91where = ( 92"no process group" if self .pgid is None else f"process group { self . pgid } " 93 ) 94outcome = "terminated" if self .gone else "SURVIVED" 95sent = "+" .join (self .signals )if self .signals else "no signal" 96detail = ( 97"; " .join ((* self .failures ,self .note )) 98if self .note 99else "; " .join (self .failures ) 100 ) 101return f" { where } { outcome } after { sent } " + (f": { detail } " if detail else "" ) 102 103def as_json (self )-> dict [str ,object ]: 104return { 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).""" 115try : 116return stat .rsplit (b")" ,1 )[1 ].split ()[19 ].decode () 117except (IndexError ,UnicodeDecodeError ): 118return None 119 120 121def leader_token (pid :int )-> str | None : 122"""Identity token for a live pid: its start time, from /proc or, on 123Darwin, its process table entry. None where neither can tell.""" 124try : 125with open (f"/proc/ { pid } /stat" ,"rb" )as stat : 126return start_token (stat .read ()) 127except OSError : 128return 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.""" 134mib = (ctypes .c_int * 4 )(1 ,14 ,1 ,pid ) 135buffer = ctypes .create_string_buffer (1024 ) 136size = ctypes .c_size_t (len (buffer )) 137if LIBC .sysctl (mib ,4 ,buffer ,ctypes .byref (size ),None ,0 )!= 0 or size .value < 12 : 138return None 139seconds ,micros = struct .unpack_from ("=qi" ,buffer .raw ) 140return 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 145unavailable or incomplete.""" 146try : 147entries = os .listdir ("/proc" ) 148except OSError : 149if sys .platform != "darwin" : 150return None 151# Darwin has no /proc. Ask its process table; EPERM alone never means dead. 152try : 153result = 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 ], 166capture_output = True , 167text = True , 168timeout = 0.5 , 169check = True , 170 ) 171rows = [line .split ()for line in result .stdout .splitlines ()] 172return [ 173 (int (pid ),int (ppid ),int (pgid ),state .startswith ("Z" )) 174for pid ,ppid ,pgid ,state in rows 175 ] 176except (OSError ,subprocess .SubprocessError ,ValueError ): 177return None 178processes = [] 179for entry in entries : 180if not entry .isdigit (): 181continue 182try : 183with open (f"/proc/ { entry } /stat" ,"rb" )as stat : 184fields = stat .read ().rsplit (b")" ,1 )[1 ].split () 185processes .append ( 186 (int (entry ),int (fields [1 ]),int (fields [2 ]),fields [0 ]== b"Z" ) 187 ) 188except FileNotFoundError : 189continue # process exited while enumerating 190except (OSError ,IndexError ,ValueError ): 191return None # an incomplete view cannot prove the group empty 192return 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.""" 197processes = table () 198if processes is None : 199return None 200return [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 205parents, with their descendants in it. 206 207ssh's ControlPersist master forks its ProxyCommand, then daemonizes out of 208the job's group: the proxy stays behind, and ending it ends the master and 209every later ssh that would have ridden it. Such a parent started after the 210group's leader, from inside the job; init or a subreaper that adopted an 211orphan was running before it, so orphans are never among these. 212""" 213if not group .whole or group .leader is None : 214return set () 215processes = table () 216if processes is None : 217return set () 218groups = {pid :pgid for pid ,_ ,pgid ,_ in processes } 219members = { 220pid :ppid 221for pid ,ppid ,pgid ,zombie in processes 222if pgid == group .pgid and not zombie 223 } 224begun = float (group .leader ) 225kept = set () 226for pid ,ppid in members .items (): 227if groups .get (ppid ,group .pgid )== group .pgid : 228continue 229started = leader_token (ppid ) 230if started is not None and float (started )>= begun : 231kept .add (pid ) 232while descendants := { 233pid for pid ,ppid in members .items ()if ppid in kept and pid not in kept 234 }: 235kept |= descendants 236return 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.""" 246try : 247deliver (group ,0 ) 248except ProcessLookupError : 249return False 250except PermissionError : 251return True # existence is known; inability to signal is a cleanup failure 252if group .leader is None : 253return True 254token = leader_token (group .pgid ) 255return 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.""" 260if not alive (group ): 261return False 262if not group .whole : 263return True 264members = live_members (group .pgid ) 265return 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.""" 270try : 271deliver (group ,sig ) 272return None 273except ProcessLookupError : 274return None # already gone; the probe decides 275except OSError as error : 276return 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.""" 281deadline = asyncio .get_running_loop ().time ()+ window 282while True : 283remaining = {pgid :group for pgid ,group in live .items ()if alive (group )} 284if not remaining or asyncio .get_running_loop ().time ()>= deadline : 285return remaining 286await asyncio .sleep (PROBE_INTERVAL ) 287 288 289async def terminate ( 290groups :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.""" 293live = {group .pgid :group for group in groups if alive (group )} 294sent :dict [int ,list [str ]]= {pgid : []for pgid in live } 295failures :dict [int ,list [str ]]= {pgid : []for pgid in live } 296for sig ,window in ((signal .SIGTERM ,term ), (signal .SIGKILL ,kill )): 297for pgid ,group in live .items (): 298failure = send_signal (group ,sig ) 299sent [pgid ].append (signal .Signals (sig ).name ) 300if failure is not None : 301failures [pgid ].append (failure ) 302live = await settled (live ,window ) 303if not live : 304break 305endings = [] 306for group in groups : 307pgid ,signals = group .pgid ,sent .get (group .pgid , []) 308surviving ,note = pgid in live ,"" 309if surviving and not current (group ): 310surviving ,note = False ,"only non-running members remained" 311endings .append ( 312Termination ( 313pgid ,tuple (signals ),not surviving ,tuple (failures .get (pgid , [])),note 314 ) 315 ) 316return 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 322are that process's (`adopted`) and stay; the rest are ended one by one.""" 323if not (leave_adopted and alive (group )): 324return (await terminate ([group ]))[0 ] 325signals :list [str ]= [] 326failures :list [str ]= [] 327rounds = 0 328while True : 329kept = adopted (group ) 330if not kept : 331return (await terminate ([group ]))[0 ] 332rest = [pid for pid in live_members (group .pgid )or []if pid not in kept ] 333if not rest or rounds == 3 :# a member may fork while the others end 334note = f"kept { len ( kept ) } that a process which left the group parents" 335return Termination ( 336group .pgid ,tuple (signals ),not rest ,tuple (failures ),note 337 ) 338rounds += 1 339targets = [Group (pid ,leader_token (pid ),whole = False )for pid in rest ] 340for ending in await terminate (targets ): 341signals += [name for name in ending .signals if name not in signals ] 342failures += ending .failures