mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
509 lines
19 KiB
Python
Executable File
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())
|