"""Real boot consumer, inert Python children; Linux subreaper owns red cleanup.""" import ctypes import json import os from pathlib import Path import shutil import signal import subprocess import sys import tempfile import time import unittest SOURCE = Path(sys.argv.pop(1)).resolve() STUB = '''import json, os, signal, time from pathlib import Path name = Path(__file__).parent.name if Path(__file__).parent.name == 'business' else 'adapter' root = Path.cwd() def stop(signum, frame): if (root/'ignore-term').exists() and name == 'business': return (root / (name + '.stopped')).write_text(str(signum)) raise SystemExit(0) signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) ready = root / (name + '.ready') ready_tmp = root / (name + '.ready.tmp-' + str(os.getpid())) ready_tmp.write_text(json.dumps({'pid':os.getpid(), 'env':{k:v for k,v in os.environ.items() if k.startswith('JWB_')}})) os.replace(ready_tmp, ready) while True: if name == 'adapter' and (root/'exit-adapter').exists(): raise SystemExit(7) time.sleep(.02) ''' class BootTests(unittest.TestCase): @classmethod def setUpClass(cls): # Reap only our own deliberately orphaned fixture descendants on RED. if ctypes.CDLL(None, use_errno=True).prctl(36, 1, 0, 0, 0) != 0: raise OSError(ctypes.get_errno(), 'subreaper unavailable') def run_case(self, signum, stubborn=False): with tempfile.TemporaryDirectory(prefix='justworks-boot-') as tmp: root = Path(tmp) if stubborn: (root/'ignore-term').touch() (root/'business').mkdir() (root/'business/server.py').write_text(STUB) (root/'server.py').write_text(STUB) shutil.copyfile(SOURCE, root/'boot.py') env = {'PATH': str(Path(sys.executable).parent) + ':/usr/bin:/bin', 'HOME': str(root), 'TMPDIR': str(root), 'PYTHONDONTWRITEBYTECODE': '1'} proc = subprocess.Popen([sys.executable, '-I', '-S', '-B', 'boot.py'], cwd=root, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE) pids = [] try: deadline = time.monotonic()+3 while not all((root/(n+'.ready')).exists() for n in ['business','adapter']): if proc.poll() is not None or time.monotonic()>deadline: self.fail('fixture children failed readiness') time.sleep(.01) ready = {n:json.loads((root/(n+'.ready')).read_text()) for n in ['business','adapter']} pids = [r['pid'] for r in ready.values()] self.assertEqual(ready['business']['env'], { 'JWB_HOST':'127.0.0.1', 'JWB_PORT':'4310', 'JWB_BASE_PATH':'/business-demo', 'JWB_DATA_DIR':'/data/business', 'JWB_AUTH_MODE':'enforce', 'JWB_COOKIE_SECURE':'0', 'JWB_ARCHIPELAGO':'1'}) self.assertEqual(ready['adapter']['env'], {}) if signum is None: (root/'exit-adapter').touch() else: proc.send_signal(signum) proc.wait(timeout=8) self.assertEqual(proc.returncode, 1 if signum is None else 0) for name in ready: if (stubborn and name == 'business') or (signum is None and name == 'adapter'): self.assertFalse((root/(name+'.stopped')).exists()) else: self.assertTrue((root/(name+'.stopped')).exists(), name+' missed graceful termination') for pid in pids: with self.assertRaises(ProcessLookupError): os.kill(pid, 0) finally: if proc.poll() is None: proc.kill() # Do not signal process groups or discover any unrelated process. for file in [root/'business.ready',root/'adapter.ready']: if file.exists(): pid=json.loads(file.read_text())['pid'] try: os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass try: os.waitpid(pid, 0) except ChildProcessError: pass proc.communicate(timeout=3) def test_sigterm_reaches_both_children_and_reaps(self): self.run_case(signal.SIGTERM) def test_resistant_child_is_killed_and_reaped(self): self.run_case(signal.SIGTERM, stubborn=True) def test_sigint_reaches_both_children_and_reaps(self): self.run_case(signal.SIGINT) def test_child_failure_stops_sibling_and_remains_failure(self): self.run_case(None) if __name__ == '__main__': unittest.main(verbosity=2)