diff --git a/tests/test_session_supervisor.py b/tests/test_session_supervisor.py index 476e4d0..a2f5d8f 100644 --- a/tests/test_session_supervisor.py +++ b/tests/test_session_supervisor.py @@ -3,7 +3,11 @@ import importlib.util import io import os from pathlib import Path +import signal +import sys import tempfile +import threading +import time import unittest ROOT = Path(__file__).resolve().parents[1] @@ -207,6 +211,129 @@ class SupervisorModeTests(unittest.TestCase): self.assertNotIn('guest session stopped', output) +class ExitStatusTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.prefix = self.root / '.darling' + self.prefix.mkdir() + self.proc = self.root / 'proc' + self.proc.mkdir() + + def run_supervisor(self, command, remaining): + previous = supervisor.cleanup + supervisor.cleanup = lambda *_a, **_k: remaining + self.addCleanup(setattr, supervisor, 'cleanup', previous) + return supervisor.run_supervised( + command, self.prefix, None, proc_root=self.proc, poll=0, + run=lambda *_a, **_k: None, kill=lambda *_a: None, + sleep=lambda _s: None, out=io.StringIO()) + + def test_child_status_is_kept_when_cleanup_is_clean(self): + self.assertEqual(self.run_supervisor(['sh', '-c', 'exit 3'], []), 3) + + def test_surviving_processes_force_a_failure_status(self): + self.assertEqual(self.run_supervisor(['true'], [1234]), 1) + + def test_a_failed_child_stays_failed_with_survivors(self): + self.assertEqual(self.run_supervisor(['sh', '-c', 'exit 3'], [1234]), 3) + + +class SignalEscalationTests(unittest.TestCase): + """A signal must start the TERM grace period, then kill the whole group.""" + + CHILD = ( + "import os, signal, subprocess, sys, time\n" + "signal.signal(signal.SIGTERM, signal.SIG_IGN)\n" + "worker = subprocess.Popen([sys.executable, '-c', " + "'import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(3)'])\n" + "with open(sys.argv[1], 'w') as handle:\n" + " handle.write('%d %d' % (os.getpid(), worker.pid))\n" + "time.sleep(3)\n" + ) + + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.prefix = self.root / '.darling' + self.prefix.mkdir() + self.proc = self.root / 'proc' + self.proc.mkdir() + self.pids = self.root / 'pids' + self.script = self.root / 'ignore-term.py' + self.script.write_text(self.CHILD) + previous = supervisor.TERM_GRACE + supervisor.TERM_GRACE = 0.2 + self.addCleanup(setattr, supervisor, 'TERM_GRACE', previous) + + def wait_dead(self, pid, timeout=10): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + state = Path('/proc/%d/stat' % pid).read_text().rsplit(')', 1)[1].split()[0] + except (OSError, IndexError): + return True + if state == 'Z': + return True + time.sleep(0.02) + return False + + def interrupt_when_ready(self): + deadline = time.monotonic() + 20 + while not self.pids.exists() and time.monotonic() < deadline: + time.sleep(0.02) + os.kill(os.getpid(), signal.SIGTERM) + + def test_sigterm_reaches_the_grace_period_and_the_group_is_killed(self): + thread = threading.Thread(target=self.interrupt_when_ready, daemon=True) + thread.start() + output = io.StringIO() + try: + code = supervisor.run_supervised( + [sys.executable, str(self.script), str(self.pids)], self.prefix, None, + proc_root=self.proc, poll=0, run=lambda *_a, **_k: None, + kill=lambda *_a: None, sleep=lambda _s: time.sleep(0.01), out=output) + finally: + thread.join(timeout=5) + parent, worker = (int(part) for part in self.pids.read_text().split()) + self.assertEqual(code, 137) + self.assertTrue(self.wait_dead(parent), 'the loader survived the escalation') + self.assertTrue(self.wait_dead(worker), 'the descendant survived the escalation') + self.assertIn('interrupted by signal 15', output.getvalue()) + + +class IdleEnvironmentTests(unittest.TestCase): + def setUp(self): + self.previous = os.environ.get('RCLOL_STOP_AFTER_CLIENT_SECONDS') + self.addCleanup(self.restore) + + def restore(self): + if self.previous is None: + os.environ.pop('RCLOL_STOP_AFTER_CLIENT_SECONDS', None) + else: + os.environ['RCLOL_STOP_AFTER_CLIENT_SECONDS'] = self.previous + + def test_absent_value_disables_the_idle_stop(self): + os.environ.pop('RCLOL_STOP_AFTER_CLIENT_SECONDS', None) + self.assertEqual(supervisor.env_seconds('RCLOL_STOP_AFTER_CLIENT_SECONDS'), 0) + + def test_valid_value_is_used(self): + os.environ['RCLOL_STOP_AFTER_CLIENT_SECONDS'] = ' 120 ' + self.assertEqual(supervisor.env_seconds('RCLOL_STOP_AFTER_CLIENT_SECONDS'), 120) + + def test_invalid_value_warns_and_disables_the_idle_stop(self): + os.environ['RCLOL_STOP_AFTER_CLIENT_SECONDS'] = '5m' + output = io.StringIO() + self.assertEqual(supervisor.env_seconds('RCLOL_STOP_AFTER_CLIENT_SECONDS', output), 0) + self.assertIn("ignoring invalid RCLOL_STOP_AFTER_CLIENT_SECONDS='5m'", output.getvalue()) + + def test_empty_value_disables_the_idle_stop(self): + os.environ['RCLOL_STOP_AFTER_CLIENT_SECONDS'] = '' + self.assertEqual(supervisor.env_seconds('RCLOL_STOP_AFTER_CLIENT_SECONDS'), 0) + + class IdleDecisionTests(unittest.TestCase): def test_window_present_resets_the_timer(self): self.assertEqual(supervisor.idle_decision(True, 100.0, 200.0, True, False, 60), diff --git a/tools/session-supervisor.py b/tools/session-supervisor.py index 5f0e140..aeb3f64 100755 --- a/tools/session-supervisor.py +++ b/tools/session-supervisor.py @@ -374,7 +374,7 @@ def run_supervised(command, prefix, darling, stop_after=0, clean=True, seen_client = False idle_since = None try: - while child.poll() is None: + while child.poll() is None and not stop: # Learn the guest namespace while the session runs: after a crash # the processes that still carry the socket may be the only proof. if namespace is None: @@ -415,7 +415,21 @@ def run_supervised(command, prefix, darling, stop_after=0, clean=True, own_namespace=own_namespace) if stop: log('interrupted by signal %d' % stop[0], out) - return 0 if remaining else (code if code >= 0 else 128 - code) + status = code if code >= 0 else 128 - code + # A surviving guest process is a failed cleanup, not a clean exit. + return (status or 1) if remaining else status + + +def env_seconds(name, out=sys.stderr): + """Seconds from an optional environment variable; 0 when unset or invalid.""" + raw = os.environ.get(name, '').strip() + if not raw: + return 0 + try: + return int(raw) + except ValueError: + log('ignoring invalid %s=%r' % (name, raw), out) + return 0 def main(argv=None): @@ -423,7 +437,7 @@ def main(argv=None): parser.add_argument('--prefix', type=Path, required=True) parser.add_argument('--darling', help='loader used for a graceful shutdown') parser.add_argument('--stop-after', type=int, - default=int(os.environ.get('RCLOL_STOP_AFTER_CLIENT_SECONDS', '0')), + default=env_seconds('RCLOL_STOP_AFTER_CLIENT_SECONDS'), help='stop after the client window has been gone this many seconds') parser.add_argument('--no-clean', action='store_true', help='leave the runtime alone after the child exits')