okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
9f86700
main
1"""OS process-group identity and shutdown races need controlled child processes rather than flaky daemon timing.""" 2 3from __future__import annotations 4 5from collections .abc import Sequence 6from typing import cast 7import asyncio 8import os 9import sys 10import time 11import unittest 12 13ROOT = os .path .dirname (os .path .dirname (os .path .dirname (os .path .abspath (__file__ )))) 14sys .path .insert (0 ,os .path .join (ROOT ,"priv" ,"python" )) 15 16import albedo_api # noqa: E402 17import albedo_cells # noqa: E402 18import albedo_proc # noqa: E402 19from albedo_plugins import run as plugin # noqa: E402 20 21LOOP = asyncio .new_event_loop () 22asyncio .set_event_loop (LOOP ) 23albedo_proc .reap_stopped_safely (LOOP ) 24EVENTS :list [dict [str ,object ]]= [] 25 26 27def install ()-> None : 28plugin .setup ( 29albedo_api .PythonApi ( 30version = 1 , 31loop = LOOP , 32host = lambda method ,args : (_ for _ in ()).throw ( 33RuntimeError ("no host in tests" ) 34 ), 35HostError = RuntimeError , 36capture = albedo_cells .background_capture , 37preview = albedo_cells .PREVIEW , 38send = EVENTS .append , 39on_shutdown = lambda close :None , 40background_handle = lambda _ :None , 41 ) 42 ) 43 44 45def run (coro ): 46return LOOP .run_until_complete (coro ) 47 48 49def start (command :str ,timeout :float = 300 ): 50"""Start a shell line as a job without waiting for it; supervision is the 51same for any program, and a shell makes descendants easy to arrange.""" 52return plugin .start (["/bin/sh" ,"-c" ,command ],timeout ) 53 54 55def quick (term :float = 0.01 ,kill :float = 0.02 ): 56"""Patch the ladder windows so a refusing group is tested without real waiting.""" 57real = albedo_proc .terminate 58quick_term ,quick_kill = term ,kill 59 60async def patched ( 61groups :Sequence [albedo_proc .Group ],term :float = 0.0 ,kill :float = 0.0 62 )-> list [albedo_proc .Termination ]: 63return await real (groups ,quick_term ,quick_kill ) 64 65setattr (albedo_proc ,"terminate" ,patched ) 66return real 67 68 69def wait (job ): 70return run (job ) 71 72 73def present (pgid :int )-> bool : 74try : 75os .killpg (pgid ,0 ) 76return True 77except ProcessLookupError : 78return False 79except PermissionError : 80return True 81 82 83def pid_present (pid :int )-> bool : 84try : 85os .kill (pid ,0 ) 86return True 87except ProcessLookupError : 88return False 89except PermissionError : 90return True 91 92 93def ready (job ,marker :str = "ready" )-> None : 94"""Wait for the job to print `marker`. A shell that has not yet run its 95first line cannot be said to refuse a signal or to have started a child, and 96how soon it runs one depends on the machine's load, not on the code.""" 97deadline = time .monotonic ()+ 30 98while marker not in job .tail (): 99assert time .monotonic ()< deadline ,f"job never printed { marker !r } " 100run (asyncio .sleep (0.01 )) 101 102 103def pid_from (output :str )-> int : 104return int (output .strip ().splitlines ()[0 ]) 105 106 107class SupervisionTest (unittest .TestCase ): 108def setUp (self ): 109EVENTS .clear () 110install () 111 112def test_deadline_ends_the_whole_group (self ): 113# The deadline must outlast the shell's first lines on a loaded machine, 114# so wait for the child's pid before relying on the deadline to end it. 115job = start ("sleep 30 & echo $!; wait" ,timeout = 2 ) 116ready (job ,"\n" ) 117wait (job ) 118self .assertTrue (job .timed_out ) 119self .assertTrue (job .termination .gone ) 120self .assertFalse (present (job .group .pgid )) 121self .assertFalse (pid_present (pid_from (job .tail ()))) 122self .assertIn ("deadline exceeded after 2s" ,job .tail ()) 123self .assertIn ("terminated" ,job .tail ()) 124 125def test_command_exit_ends_descendants_that_hold_no_output (self ): 126started = time .monotonic () 127job = start ("sleep 30 >/dev/null 2>&1 & echo $!; exit 0" ,timeout = 30 ) 128wait (job ) 129grandchild = pid_from (job .tail ()) 130self .assertLess (time .monotonic ()- started ,5 )# no wait for the deadline 131self .assertFalse (job .timed_out ) 132self .assertTrue (job .termination .gone ) 133self .assertFalse (pid_present (grandchild )) 134 135def test_command_exit_leaves_what_a_process_that_left_the_group_parents (self ): 136# Like ssh with ControlPersist: a master forks its proxy, daemonizes out 137# of the job (setsid), and outlives it; the proxy stays in the group. 138# A plain background child is an orphan once the command ends. 139script = ( 140"import os, sys, time\n" 141"if os.fork() == 0:\n" 142" proxy = os.fork()\n" 143" if proxy == 0:\n" 144" os.execvp('sleep', ['sleep', '31'])\n" 145" os.setsid()\n" 146" print('proxy', proxy, 'master', os.getpid(), flush=True)\n" 147" os.execvp('sleep', ['sleep', '32'])\n" 148"orphan = os.fork()\n" 149"if orphan == 0:\n" 150" os.execvp('sleep', ['sleep', '33'])\n" 151"print('orphan', orphan, flush=True)\n" 152"time.sleep(0.5)\n" 153 ) 154job = plugin .start ([sys .executable ,"-c" ,script ],30 ) 155wait (job ) 156pids = dict (zip (* [iter (job .tail ().split ())]* 2 )) 157proxy ,master ,orphan = ( 158int (pids [name ])for name in ("proxy" ,"master" ,"orphan" ) 159 ) 160self .addCleanup (os .kill ,master ,9 ) 161self .addCleanup (os .kill ,proxy ,9 ) 162ending ,group = job .termination ,job .group 163assert ending is not None and group is not None 164self .assertTrue (ending .gone ,ending ) 165self .assertIn ("kept 1 that a process which left the group" ,ending .note ) 166self .assertTrue (pid_present (proxy )) 167self .assertTrue (pid_present (master )) 168self .assertFalse (pid_present (orphan )) 169# A stop or a deadline still ends everything in the group. 170self .assertTrue (run (albedo_proc .end (group )).gone ) 171self .assertEqual (albedo_proc .live_members (group .pgid ), []) 172 173def test_stop_escalates_and_is_retryable (self ): 174job = start ("trap '' TERM; echo ready; while :; do sleep 0.2; done" ,timeout = 30 ) 175self .addCleanup (lambda :run (job .stop ())) 176ready (job )# the trap is installed before the first signal is sent 177self .assertIsNone (job .poll ())# still running 178ending = run (job .stop ()) 179self .assertTrue (ending .gone ) 180self .assertEqual (ending .signals , ("SIGTERM" ,"SIGKILL" )) 181self .assertIs (run (job .stop ()),ending )# a reached verdict is not re-run 182run (job ) 183self .assertIn (job .id ,plugin .retained ) 184 185def test_surviving_group_stays_owned_and_is_surfaced (self ): 186"""A group that outlives KILL stays owned and is reported, not forgotten.""" 187real = ( 188albedo_proc .alive , 189albedo_proc .current , 190plugin .SHUTDOWN_TERM , 191plugin .SHUTDOWN_KILL , 192quick (), 193 ) 194 195def still_alive (group :albedo_proc .Group )-> bool : 196return True 197 198setattr (albedo_proc ,"alive" ,still_alive ) 199setattr (albedo_proc ,"current" ,still_alive ) 200plugin .SHUTDOWN_TERM ,plugin .SHUTDOWN_KILL = 0.01 ,0.02 201try : 202job = start ("true" ) 203wait (job ) 204self .assertFalse (job .termination .gone ) 205self .assertIn (job .id ,plugin .active )# surviving work stays owned 206self .assertNotIn (job .id ,plugin .retained ) 207self .assertIn ("cleanup failed" ,job .tail ()) 208run (plugin .close ()) 209finally : 210 ( 211albedo_proc .alive , 212albedo_proc .current , 213plugin .SHUTDOWN_TERM , 214plugin .SHUTDOWN_KILL , 215albedo_proc .terminate , 216 )= real 217cleanup = [event for event in EVENTS if event .get ("type" )== "cleanup" ] 218self .assertEqual (len (cleanup ),1 ) 219self .assertTrue ( 220any ( 221"SURVIVED" in failure 222for failure in cast (list [str ],cleanup [0 ]["failures" ]) 223 ) 224 ) 225 226def test_ladder_reports_gone_and_refuses_reuse (self ): 227self .assertFalse (albedo_proc .alive (albedo_proc .Group (999_999 ))) 228ending = run (albedo_proc .terminate ([albedo_proc .Group (999_999 )])) 229self .assertEqual (ending [0 ].signals , ()) 230self .assertTrue (ending [0 ].gone ) 231leader = run ( 232asyncio .create_subprocess_shell ("sleep 30" ,start_new_session = True ) 233 ) 234try : 235# Linux and Darwin both give a process an identity beyond its pid. 236self .assertIsNotNone (albedo_proc .leader_token (leader .pid )) 237group = albedo_proc .Group (leader .pid ,"not-the-leader-we-created" ) 238self .assertFalse (albedo_proc .alive (group )) 239self .assertTrue (present (leader .pid ))# and it was left alone 240finally : 241run (albedo_proc .terminate ([albedo_proc .Group (leader .pid )])) 242run (leader .wait ()) 243 244def test_stop_before_spawn_and_immediate_cancellation_keep_ownership (self ): 245async def check (): 246job = start ("sleep 30" ) 247ending = await job .stop () 248await job 249self .assertTrue (ending .gone ) 250if job .group is not None : 251self .assertFalse (present (job .group .pgid )) 252self .assertNotIn (job .id ,plugin .active ) 253cancelled = start ("sleep 30" ) 254cancelled .task .cancel () 255await asyncio .sleep (0 ) 256await cancelled .stop () 257if cancelled .group is not None : 258self .assertFalse (present (cancelled .group .pgid )) 259self .assertNotIn (cancelled .id ,plugin .active ) 260 261run (check ()) 262 263def test_retrying_failed_cleanup_releases_quota (self ): 264from unittest .mock import patch 265 266async def refused (groups ,* args ,** kwargs ): 267return [ 268albedo_proc .Termination (group .pgid , (),False , ("fixture refusal" ,)) 269for group in groups 270 ] 271 272job = start ("true" ) 273with patch .object (albedo_proc ,"terminate" ,side_effect = refused ): 274wait (job ) 275self .assertIn (job .id ,plugin .active ) 276self .assertTrue (run (job .stop ()).gone ) 277self .assertNotIn (job .id ,plugin .active ) 278self .assertTrue (cast (dict [str ,object ],EVENTS [- 1 ]["cleanup" ])["gone" ]) 279 280def test_helper_watchdog_terminates_native_code_even_if_alarm_was_ignored (self ): 281import signal 282import subprocess 283 284helper = os .path .join (ROOT ,"priv" ,"python" ,"albedo_signal.py" ) 285code = ( 286f"import sys; sys.path.insert(0, { os . path . dirname ( helper )!r } )\n" 287"import ctypes, runpy, signal, albedo_proc\n" 288"signal.signal(signal.SIGALRM, signal.SIG_IGN)\n" 289# the helper's alarm, fired sooner: it still has to arm it and 290# restore the default action, or the sleep outlasts the timeout 291"signal.alarm = lambda seconds: signal.setitimer(signal.ITIMER_REAL, 0.2)\n" 292"async def stuck(*args, **kwargs):\n ctypes.PyDLL(None).sleep(30)\n" 293"albedo_proc.terminate = stuck\n" 294"sys.argv = ['helper', '{\"targets\": []}']\n" 295f"runpy.run_path( { helper !r } , run_name='__main__')\n" 296 ) 297started = time .monotonic () 298result = subprocess .run ( 299 [sys .executable ,"-c" ,code ],capture_output = True ,timeout = 5 300 ) 301self .assertEqual (result .returncode ,- signal .SIGALRM ,result .stderr ) 302self .assertLess (time .monotonic ()- started ,5 ) 303 304def test_start_token_reads_a_kernel_stat_line (self ): 305line = b"1234 (some cmd) S 1 1234 1234 0 -1 4194560 1 0 0 0 5 3 0 0 20 0 3 0 987654 1 2" 306self .assertEqual (albedo_proc .start_token (line ),"987654" ) 307self .assertIsNone (albedo_proc .start_token (b"nonsense" )) 308 309 310if __name__ == "__main__" : 311unittest .main (verbosity = 2 )