Files
darling-lol/tools/session-supervisor.py
T

509 lines
19 KiB
Python
Executable File

#!/usr/bin/env python3
"""Supervise one guest session so a launcher exit does not orphan it.
The launcher runs this script instead of exec'ing the guest client directly. It
forwards SIGINT/SIGTERM/SIGHUP to the child, and after the child exits it asks
darlingserver to shut down and stops any process still inside the guest PID
namespace. That covers the two ways a session leaks: the Riot Client stays
resident after its window closes, and a killed loader leaves launchd/mldr and
the client tree behind.
The guest namespace is captured while the session runs, from a process that
carries this prefix's `__mldr_sockpath`; without that evidence cleanup never
touches a process. If the namespace cannot be read, only processes that carry
the socket are stopped. Cleanup only runs when this launcher started the
runtime: a second launch that attaches to an existing server leaves it alone
when its own loader exits.
Optional idle stop: with RCLOL_STOP_AFTER_CLIENT_SECONDS=N, the supervisor
stops the session once a managed League/Riot window has been absent for N
seconds and no game process is running. It is off by default and never stops
when the window state cannot be read.
Set RCLOL_CLEAN_ON_EXIT=0 to keep the previous behaviour: the runtime is left
alone when the launcher exits.
Usage (from run-riot-client.sh):
python3 session-supervisor.py --prefix ~/.darling --darling /path/darling \
-- python3 exec-guest.py env ... darling ...
"""
import argparse
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
SOCK_VARIABLE = b'__mldr_sockpath'
SOCK_NAME = '.darlingserver.sock'
GAME_NAME = 'LeagueofLegends'
CLIENT_CLASSES = ('leagueclientux', 'riot client')
POLL_SECONDS = 2.0
TERM_GRACE = 5.0
KILL_GRACE = 5.0
def log(message, out=sys.stdout):
"""Best-effort output; a closed terminal must not abort cleanup."""
try:
print('session-supervisor: %s' % message, file=out, flush=True)
except (OSError, ValueError):
pass
def process_info(proc):
"""Return (state, ppid, start_ticks), or None when stat is unreadable."""
try:
fields = (proc / 'stat').read_text().rsplit(')', 1)[1].split()
if len(fields) < 20:
return None
return fields[0], int(fields[1]), int(fields[19])
except (OSError, ValueError, IndexError):
return None
def command_names(proc):
try:
raw = (proc / 'cmdline').read_bytes()
except OSError:
return []
return [os.path.basename(part.decode(errors='replace'))
for part in raw.split(b'\0') if part]
def command_line(proc):
"""Raw argv as text, without basenaming, for interpreter/path checks."""
try:
raw = (proc / 'cmdline').read_bytes()
except OSError:
return []
return [os.fsdecode(part) for part in raw.split(b'\0') if part]
def environment(proc):
try:
return dict(
item.split(b'=', 1)
for item in (proc / 'environ').read_bytes().split(b'\0')
if b'=' in item
)
except OSError:
return {}
def socket_prefix(proc):
"""The prefix named by a process's loader socket, or None."""
raw = environment(proc).get(SOCK_VARIABLE)
if raw is None:
return None
try:
path = Path(os.fsdecode(raw))
return path.parent.resolve()
except (OSError, RuntimeError, ValueError):
return None
def owned_by_prefix(proc, prefix):
if process_info(proc) is None:
return False
try:
if proc.stat().st_uid != os.getuid():
return False
except OSError:
return False
return socket_prefix(proc) == prefix
def prefix_namespace(prefix, proc_root=Path('/proc')):
"""The guest PID namespace, proved by a process carrying this socket."""
for proc in proc_root.iterdir() if proc_root.exists() else ():
if not proc.name.isdigit():
continue
if owned_by_prefix(proc, prefix):
try:
return os.readlink(proc / 'ns/pid')
except OSError:
return None
return None
def namespace_members(namespace, proc_root=Path('/proc'), own_namespace=None):
"""Processes inside namespace, excluding this host process.
A namespace equal to the supervisor's own is refused: a mistaken capture
must never turn session cleanup into a host-wide kill.
"""
if not namespace or namespace == own_namespace:
return []
found = []
for proc in proc_root.iterdir() if proc_root.exists() else ():
if not proc.name.isdigit():
continue
try:
if os.readlink(proc / 'ns/pid') != namespace:
continue
if proc.stat().st_uid != os.getuid():
continue
except OSError:
continue
found.append((int(proc.name), proc))
return found
def prefix_only(prefix, proc_root=Path('/proc')):
"""Fallback: processes that positively carry this prefix's socket."""
found = []
for proc in proc_root.iterdir() if proc_root.exists() else ():
if proc.name.isdigit() and owned_by_prefix(proc, prefix):
found.append((int(proc.name), proc))
return found
def deepest_first(members, proc_root=Path('/proc')):
"""Children before parents, so live parents can reap their own tree."""
parents = {}
for pid, proc in members:
info = process_info(proc)
parents[pid] = info[1] if info else None
known = {pid for pid, _proc in members}
def depth(pid, seen):
parent = parents.get(pid)
if parent not in known or parent == pid or parent in seen:
return 0
return depth(parent, seen | {pid}) + 1
return sorted((pid for pid, _proc in members),
key=lambda pid: (-depth(pid, set()), pid))
def wrapper(proc, name):
"""Return (state, comm, ppid) for a /proc entry, or None."""
try:
state = (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0]
comm = (proc / 'comm').read_text().strip()
parent = int((proc / 'stat').read_text().rsplit(')', 1)[1].split()[1])
return state, comm, parent
except (OSError, ValueError, IndexError):
return None
def process_identity(proc):
"""Identity captured before shutdown, not just a reusable PID."""
info = process_info(proc)
if info is None:
return None
try:
return info[2], os.readlink(proc / 'ns/pid'), proc.stat().st_uid
except OSError:
return None
def signal_process(pid, proc, identity, signum, kill=os.kill):
"""Pin the process before the final identity check to close the PID reuse race."""
if identity is None or process_identity(proc) != identity:
return
if kill is not os.kill:
# Injected signal transport for offline process fixtures.
kill(pid, signum)
return
try:
descriptor = os.pidfd_open(pid)
except (AttributeError, OSError):
# Never fall back to signalling an unpinned PID.
return
try:
if process_identity(proc) == identity:
signal.pidfd_send_signal(descriptor, signum)
except (AttributeError, OSError):
pass
finally:
os.close(descriptor)
def stop_processes(members, kill=os.kill, sleep=time.sleep, proc_root=Path('/proc'),
out=sys.stdout, term_grace=TERM_GRACE, kill_grace=KILL_GRACE,
clock=time.monotonic, identities=None):
"""SIGTERM then SIGKILL a set of processes; return what is still present."""
if identities is None:
identities = {pid: process_identity(proc) for pid, proc in members}
def present(pid, proc):
return (identities.get(pid) is not None
and process_identity(proc) == identities[pid])
def alive(pid, proc):
info = process_info(proc)
return present(pid, proc) and info is not None and info[0] != 'Z'
processes = dict(members)
remaining = [(pid, proc) for pid, proc in members if alive(pid, proc)]
for pid in deepest_first(remaining, proc_root):
try:
signal_process(pid, processes[pid], identities[pid], signal.SIGTERM, kill)
except OSError:
pass
deadline = clock() + term_grace
while remaining and clock() < deadline:
sleep(POLL_SECONDS)
remaining = [(pid, proc) for pid, proc in remaining if alive(pid, proc)]
# Re-read once before escalating: a zero grace period must not SIGKILL a
# process that already exited on SIGTERM.
remaining = [(pid, proc) for pid, proc in remaining if alive(pid, proc)]
for pid in deepest_first(remaining, proc_root):
try:
signal_process(pid, processes[pid], identities[pid], signal.SIGKILL, kill)
except OSError:
pass
deadline = clock() + kill_grace
while remaining and clock() < deadline:
sleep(POLL_SECONDS)
remaining = [(pid, proc) for pid, proc in remaining
if present(pid, proc)]
# Re-read once even when a grace period is zero, so the caller never gets a
# stale "still running" answer for a process that SIGKILL already removed.
return [(pid, proc) for pid, proc in remaining
if present(pid, proc)]
def cleanup(prefix, darling, namespace, proc_root=Path('/proc'), run=subprocess.run,
kill=os.kill, sleep=time.sleep, out=sys.stdout, own_namespace=None,
term_grace=TERM_GRACE, kill_grace=KILL_GRACE, clock=time.monotonic):
"""Shut down the runtime, then stop anything left in the guest namespace."""
if namespace is None:
namespace = prefix_namespace(prefix, proc_root)
own_namespace = own_namespace or os.readlink('/proc/self/ns/pid')
members = namespace_members(namespace, proc_root, own_namespace)
identities = {pid: process_identity(proc) for pid, proc in members}
if members and darling:
try:
run([darling, 'shutdown'], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, timeout=25)
except (OSError, subprocess.SubprocessError):
pass
deadline = clock() + term_grace
while members and clock() < deadline:
sleep(POLL_SECONDS)
members = [(pid, proc) for pid, proc in members
if identities.get(pid) is not None
and process_identity(proc) == identities[pid]]
if not members:
members = prefix_only(prefix, proc_root)
identities = {pid: process_identity(proc) for pid, proc in members}
if not members:
log('runtime and guest processes are gone', out)
return []
log('stopping %d leftover guest process(es)' % len(members), out)
remaining = stop_processes(members, kill=kill, sleep=sleep,
proc_root=proc_root, out=out, term_grace=term_grace,
kill_grace=kill_grace, clock=clock, identities=identities)
if remaining:
details = []
for pid, proc in remaining:
info = wrapper(proc, proc_root)
details.append('%d (%s, %s)' % (pid, info[1] if info else '?',
info[0] if info else '?'))
log('still present: %s' % '; '.join(details), out)
else:
log('guest session stopped', out)
return remaining
def runtime_alive(prefix, proc_root=Path('/proc')):
"""True when this prefix's darlingserver marker names a live server.
Used to tell "we started the runtime" from "we attached to one that another
launcher already owns". Cleanup must never end the second kind.
"""
try:
pid = int((prefix / '.init.pid').read_text().strip())
except (OSError, ValueError):
return False
if pid <= 0:
return False
proc = proc_root / str(pid)
info = process_info(proc)
if info is None or info[0] == 'Z':
return False
argv = command_line(proc)
if not argv or os.path.basename(argv[0]) != 'darlingserver':
return False
try:
return (proc.stat().st_uid == os.getuid() and len(argv) > 1
and Path(argv[1]).resolve() == prefix)
except OSError:
return False
def managed_client_window(run=subprocess.run):
"""True/False for a managed client window; None when it cannot be read."""
try:
listing = run(['xprop', '-root', '_NET_CLIENT_LIST'], text=True,
capture_output=True, timeout=5).stdout
except (OSError, subprocess.SubprocessError):
return None
lowered = listing.lower()
if '_net_client_list' not in lowered or 'not found' in lowered:
return None
for raw in set(re.findall(r'0x[0-9a-fA-F]+', listing)):
try:
classes = run(['xprop', '-id', raw, 'WM_CLASS'], text=True,
capture_output=True, timeout=5).stdout.lower()
except (OSError, subprocess.SubprocessError):
return None
if 'not found' in classes or '=' not in classes:
return None
if any(name in classes for name in CLIENT_CLASSES):
return True
return False
def game_running(prefix, proc_root=Path('/proc')):
"""True/False for a game of this prefix; None when /proc cannot prove it."""
if not proc_root.exists():
return None
for proc in proc_root.iterdir():
if not proc.name.isdigit():
continue
if GAME_NAME not in command_names(proc):
continue
if socket_prefix(proc) == prefix:
return True
return False
def idle_decision(seen_client, idle_since, now, windows, game, seconds):
"""Return (seen_client, idle_since, stop_now) for one poll."""
if seconds <= 0 or windows is None or game is None:
return seen_client, None, False
if game or windows:
return seen_client or game or windows, None, False
if not seen_client:
return False, None, False
if idle_since is None:
return True, now, False
return True, idle_since, now - idle_since >= seconds
def run_supervised(command, prefix, darling, stop_after=0, clean=True,
proc_root=Path('/proc'), run=subprocess.run, kill=os.kill,
sleep=time.sleep, out=sys.stdout, poll=POLL_SECONDS,
own_namespace=None, runtime_at_start=None):
"""Run command, forward termination signals, then clean the session.
Cleanup only runs when this launcher started the runtime. A second launch
while a session is alive attaches to that server, and must not shut it down
when its own loader exits.
"""
if not clean:
os.execvp(command[0], command)
owns_runtime = (runtime_at_start == 'stopped' if runtime_at_start is not None
else not runtime_alive(prefix, proc_root))
namespace = None
child = subprocess.Popen(command, start_new_session=True)
stop = []
previous = {}
def forward(signum, _frame):
stop.append(signum)
try:
os.killpg(child.pid, signal.SIGTERM)
except OSError:
pass
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
previous[signum] = signal.signal(signum, forward)
seen_client = False
idle_since = None
try:
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:
namespace = prefix_namespace(prefix, proc_root)
if stop_after > 0:
now = time.monotonic()
seen_client, idle_since, stop_now = idle_decision(
seen_client, idle_since, now, managed_client_window(run),
game_running(prefix, proc_root), stop_after)
if stop_now:
log('client window has been gone for %ds; stopping the session'
% stop_after, out)
try:
os.killpg(child.pid, signal.SIGTERM)
except OSError:
pass
break
sleep(poll)
deadline = time.monotonic() + TERM_GRACE
while child.poll() is None and time.monotonic() < deadline:
sleep(poll)
if child.poll() is None:
try:
os.killpg(child.pid, signal.SIGKILL)
except OSError:
pass
code = child.wait()
finally:
for signum, handler in previous.items():
signal.signal(signum, handler)
if not owns_runtime:
log('another session owns this runtime; leaving it running', out)
remaining = []
else:
namespace = namespace or prefix_namespace(prefix, proc_root)
remaining = cleanup(prefix, darling, namespace, proc_root=proc_root, run=run,
kill=kill, sleep=sleep, out=out,
own_namespace=own_namespace)
if stop:
log('interrupted by signal %d' % stop[0], out)
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):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('--prefix', type=Path, required=True)
parser.add_argument('--probe-runtime', action='store_true',
help='print runtime state before launcher startup helpers')
parser.add_argument('--runtime-at-start', choices=('running', 'stopped'),
help='runtime state captured before launcher startup helpers')
parser.add_argument('--darling', help='loader used for a graceful shutdown')
parser.add_argument('--stop-after', type=int,
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')
parser.add_argument('command', nargs=argparse.REMAINDER)
args = parser.parse_args(argv)
prefix = args.prefix.expanduser().resolve()
if args.probe_runtime:
print('running' if runtime_alive(prefix) else 'stopped')
return 0
command = args.command[1:] if args.command[:1] == ['--'] else args.command
if not command:
parser.error('a command is required after --')
clean = not args.no_clean and os.environ.get('RCLOL_CLEAN_ON_EXIT', '1') != '0'
return run_supervised(command, prefix, args.darling, stop_after=max(0, args.stop_after),
clean=clean, runtime_at_start=args.runtime_at_start)
if __name__ == '__main__':
sys.exit(main())