105 lines
5.5 KiB
Python
105 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Disposable actual process-tree oracles; never invoke runtime, SSH or bwrap."""
|
|
import ctypes
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
|
|
HERE=Path(__file__).resolve().parent
|
|
SPEC=importlib.util.spec_from_file_location('supervised',HERE/'process_supervision.py')
|
|
S=importlib.util.module_from_spec(SPEC);SPEC.loader.exec_module(S)
|
|
CANARY='fixture-private-stderr-must-not-appear'
|
|
# A session-escaping grandchild remains owned by the subreaper even after the
|
|
# intermediate process exits. No namespace or application launcher is involved.
|
|
LEAF="import os,time,sys;from pathlib import Path;Path(sys.argv[1]).write_text(str(os.getpid()));print(sys.argv[2],file=sys.stderr,flush=True);time.sleep(30)"
|
|
LEADER="import subprocess,sys,time;subprocess.Popen([sys.executable,'-c',sys.argv[1],sys.argv[2],sys.argv[3]],start_new_session=True);time.sleep(30)"
|
|
|
|
|
|
def wait_until(predicate,timeout=8):
|
|
end=time.monotonic()+timeout
|
|
while time.monotonic()<end:
|
|
if predicate():return
|
|
time.sleep(.02)
|
|
raise AssertionError('bounded fixture condition failed')
|
|
|
|
|
|
def not_alive(pid):
|
|
f=S.facts(pid)
|
|
return f is None or f['state']=='Z'
|
|
|
|
|
|
class ProcessSupervisionTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
# Reap only the known detached fixture watchdog after killed-controller
|
|
# tests, so this test does not depend on the workstation PID1 reaper.
|
|
assert ctypes.CDLL(None).prctl(36,1,0,0,0)==0
|
|
|
|
def setUp(self):
|
|
self.temp=tempfile.TemporaryDirectory(prefix='nk-supervision-fixture-')
|
|
self.base=Path(self.temp.name);self.sentinel=subprocess.Popen([sys.executable,'-c','import time;time.sleep(30)'])
|
|
self.addCleanup(self.cleanup)
|
|
|
|
def cleanup(self):
|
|
self.sentinel.terminate();self.sentinel.wait(timeout=3);self.temp.cleanup()
|
|
|
|
def argv(self,marker):return [sys.executable,'-c',LEADER,LEAF,str(marker),CANARY]
|
|
|
|
def assert_quiet(self,state,pid):
|
|
wait_until(lambda:(state/'quiescent.json').exists())
|
|
r=json.loads((state/'quiescent.json').read_text())
|
|
self.assertTrue(r['quiescent']);self.assertTrue(r['no_live_or_waitable_descendants'])
|
|
wait_until(lambda:not_alive(pid));self.assertIsNone(self.sentinel.poll())
|
|
for p in state.glob('*.json'):
|
|
if p.name.startswith('request-'):continue # test input deliberately contains canary
|
|
self.assertNotIn(CANARY,p.read_text())
|
|
return r
|
|
|
|
def test_success_and_bounded_stdout(self):
|
|
s=S.Supervisor(self.base/'state')
|
|
self.assertEqual(s.run([sys.executable,'-c','print("receipt")'],2,capture=True),(0,b'receipt\n'))
|
|
self.assertTrue(s.close()['quiescent'])
|
|
|
|
def test_timeout_reaps_session_escaped_descendant_and_retains_sibling(self):
|
|
state=self.base/'state';marker=self.base/'child';s=S.Supervisor(state)
|
|
code,output=s.run(self.argv(marker),.6,capture=True)
|
|
self.assertEqual(code,124);self.assertEqual(output,b'')
|
|
self.assertTrue(marker.exists());r=self.assert_quiet(state,int(marker.read_text()))
|
|
self.assertEqual(r['reason'],'timeout');self.assertTrue(s.close()['quiescent'])
|
|
|
|
def test_hard_controller_loss_reaps_descendant_and_retains_stage(self):
|
|
state=self.base/'state';marker=self.base/'child'
|
|
controller="import importlib.util,sys;from pathlib import Path;s=importlib.util.spec_from_file_location('s',sys.argv[1]);m=importlib.util.module_from_spec(s);s.loader.exec_module(m);x=m.Supervisor(Path(sys.argv[2]));x.run(sys.argv[3:],20,capture=True)"
|
|
p=subprocess.Popen([sys.executable,'-I','-B','-c',controller,str(HERE/'process_supervision.py'),str(state),*self.argv(marker)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
|
self.addCleanup(lambda:p.poll() is None and p.kill())
|
|
wait_until(marker.exists);watchdog=json.loads((state/'ready.json').read_text())['pid']
|
|
os.kill(p.pid,signal.SIGKILL);p.wait(timeout=3)
|
|
r=self.assert_quiet(state,int(marker.read_text()));self.assertEqual(r['reason'],'controller_lost')
|
|
self.assertTrue(state.exists()) # no controller remains to delete its stage
|
|
wait_until(lambda:not_alive(watchdog));os.waitpid(watchdog,0)
|
|
|
|
def test_outer_phase_timeout_stops_nested_runner_sibling(self):
|
|
state=self.base/'state';marker=self.base/'child';s=S.Supervisor(state)
|
|
phase="import importlib.util,sys;from pathlib import Path;s=importlib.util.spec_from_file_location('s',sys.argv[1]);m=importlib.util.module_from_spec(s);s.loader.exec_module(m);m.request(Path(sys.argv[2]),sys.argv[3:],20)"
|
|
code,output=s.run([sys.executable,'-I','-B','-c',phase,str(HERE/'process_supervision.py'),str(state),*self.argv(marker)],.7,capture=True)
|
|
self.assertEqual(code,124);self.assertTrue(marker.exists())
|
|
self.assert_quiet(state,int(marker.read_text()));self.assertTrue(s.close()['quiescent'])
|
|
|
|
def test_invalid_mapping_retains_failed_quiescence_evidence(self):
|
|
state=self.base/'state';s=S.Supervisor(state);owner=S.facts(os.getpid());owner['start']+=1
|
|
S.write(state/'request-invalid.json',{'owner':owner,'argv':[sys.executable,'-c','pass'],'timeout':1,'capture':False})
|
|
wait_until(lambda:(state/'quiescent.json').exists())
|
|
self.assertFalse(json.loads((state/'quiescent.json').read_text())['quiescent'])
|
|
with self.assertRaises(RuntimeError):s.close()
|
|
self.assertTrue(state.exists());self.assertIsNone(self.sentinel.poll())
|
|
|
|
|
|
if __name__=='__main__':unittest.main()
|