mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
fix(launcher): escalate after a forwarded signal and keep a failed cleanup failed
- a forwarded SIGINT/SIGTERM/SIGHUP now leaves the polling loop, so the five-second TERM grace and the SIGKILL fallback actually run; a loader that ignores SIGTERM no longer blocks the supervisor forever - surviving guest processes now return a non-zero status instead of 0 - an invalid RCLOL_STOP_AFTER_CLIENT_SECONDS warns and disables the idle stop instead of aborting the launch with a traceback Adds a regression test that sends SIGTERM to a loader and a descendant that both ignore it, and asserts the process group is killed. Signed-off-by: zero <zero@localhost>
This commit is contained in:
1 parent
b4e4a0c59b
commit
6b8cf67ca4
2 files changed
+144
-3
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user