mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
- fail closed when X11 omits _NET_CLIENT_LIST or WM_CLASS, and recognize the RiotClientServices window class; - require stale client processes to be at least 60 seconds old; - recheck process identity before SIGTERM and SIGKILL and recheck the window before signalling; - document the age and window-detection guards.
243 lines
7.8 KiB
Python
Executable File
243 lines
7.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Partially recover a live Darling runtime with a stale client tree.
|
|
|
|
This is opt-in through RCLOL_RECOVER_STALE_CLIENT=1. It only acts when all
|
|
of these are true:
|
|
|
|
- a darlingserver is running;
|
|
- no League game process is running;
|
|
- no managed Riot/League client window is visible;
|
|
- one or more Riot/League client, CEF or crash-handler processes at least 60
|
|
seconds old are still present for this prefix.
|
|
|
|
It terminates only those client processes with SIGTERM/SIGKILL. The
|
|
darlingserver, launchd/mldr services, sockets and .init.pid stay intact so the
|
|
next launcher run can reuse the runtime. It never patches files or the game.
|
|
"""
|
|
|
|
import math
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
GAME_NAME = 'LeagueofLegends'
|
|
WINDOW_CLASSES = ('leagueclientux', 'riot client', 'riotclientservices')
|
|
MIN_AGE_SECONDS = 60
|
|
CLIENT_MARKERS = (
|
|
'/Applications/League of Legends.app',
|
|
'/Applications/Riot Client.app',
|
|
'/Users/Shared/Riot Games/Riot Client.app',
|
|
'RiotClientServices',
|
|
'Riot Client',
|
|
'LeagueClient',
|
|
'LeagueClientUx',
|
|
'LeagueCrashHandler',
|
|
)
|
|
RUNTIME_NAMES = ('darlingserver', 'darling')
|
|
SOCK_VARIABLE = b'__mldr_sockpath'
|
|
SOCK_NAME = '.darlingserver.sock'
|
|
|
|
|
|
def target_prefix():
|
|
value = os.environ.get('DPREFIX', str(Path.home() / '.darling'))
|
|
prefix = Path(value).expanduser()
|
|
return prefix if prefix.is_absolute() else 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_text(proc):
|
|
try:
|
|
raw = (proc / 'cmdline').read_bytes()
|
|
except OSError:
|
|
return ''
|
|
return b' '.join(part for part in raw.split(b'\0') if part).decode(
|
|
errors='replace')
|
|
|
|
|
|
def process_state(proc):
|
|
try:
|
|
return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0]
|
|
except (OSError, IndexError):
|
|
return None
|
|
|
|
|
|
def server_running(proc_root=Path('/proc')):
|
|
try:
|
|
entries = list(proc_root.iterdir())
|
|
except OSError:
|
|
return False
|
|
for proc in entries:
|
|
if not proc.name.isdigit():
|
|
continue
|
|
try:
|
|
if (proc / 'comm').read_text().strip() == 'darlingserver':
|
|
return True
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
|
|
def game_running(proc_root=Path('/proc')):
|
|
for proc in proc_root.iterdir() if proc_root.exists() else ():
|
|
if not proc.name.isdigit():
|
|
continue
|
|
names = command_names(proc)
|
|
if GAME_NAME in names:
|
|
return True
|
|
return False
|
|
|
|
|
|
def visible_client_window():
|
|
"""Return True when a managed client window is present.
|
|
|
|
A missing xprop/property or display is treated as 'cannot prove there is no
|
|
window' so we do not kill a possibly working client. xprop exits zero and
|
|
prints "not found" for a property the window manager did not set.
|
|
"""
|
|
try:
|
|
listing = subprocess.check_output(
|
|
['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return True
|
|
lowered = listing.lower()
|
|
if '_net_client_list' not in lowered or 'not found' in lowered:
|
|
return True
|
|
for raw in re.findall(r'0x[0-9a-fA-F]+', listing):
|
|
try:
|
|
classes = subprocess.check_output(
|
|
['xprop', '-id', raw, 'WM_CLASS'], text=True, timeout=5
|
|
).lower()
|
|
except (OSError, subprocess.SubprocessError):
|
|
return True
|
|
if 'not found' in classes or '=' not in classes:
|
|
return True
|
|
if any(name in classes for name in WINDOW_CLASSES):
|
|
return True
|
|
return False
|
|
|
|
|
|
def client_identity(prefix, pid, proc_root=Path('/proc')):
|
|
"""Start ticks of a live stale client for prefix, or None when not eligible."""
|
|
proc = proc_root / str(pid)
|
|
if process_state(proc) in ('Z', None):
|
|
return None
|
|
owner = (prefix / SOCK_NAME).parent.resolve()
|
|
try:
|
|
if proc.stat().st_uid != os.getuid():
|
|
return None
|
|
names = command_names(proc)
|
|
if GAME_NAME in names or any(name in RUNTIME_NAMES for name in names):
|
|
return None
|
|
text = command_text(proc)
|
|
if not any(marker in text for marker in CLIENT_MARKERS):
|
|
return None
|
|
environment = dict(
|
|
item.split(b'=', 1)
|
|
for item in (proc / 'environ').read_bytes().split(b'\0')
|
|
if b'=' in item
|
|
)
|
|
socket = environment.get(SOCK_VARIABLE)
|
|
if socket is None:
|
|
return None
|
|
if Path(os.fsdecode(socket)).parent.resolve() != owner:
|
|
return None
|
|
uptime = float((proc_root / 'uptime').read_text().split()[0])
|
|
ticks = os.sysconf('SC_CLK_TCK')
|
|
if not math.isfinite(uptime) or uptime < 0 or ticks <= 0:
|
|
return None
|
|
# Field 22 follows a comm field that may contain spaces/parentheses.
|
|
start_ticks = int((proc / 'stat').read_text().rsplit(')', 1)[1].split()[19])
|
|
if start_ticks < 0 or uptime - start_ticks / ticks < MIN_AGE_SECONDS:
|
|
return None
|
|
return start_ticks
|
|
except (OSError, ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
def client_pids(prefix, proc_root=Path('/proc')):
|
|
"""Client/CEF PIDs belonging to prefix, excluding Darling and the game."""
|
|
found = []
|
|
try:
|
|
entries = list(proc_root.iterdir())
|
|
except OSError:
|
|
return []
|
|
for proc in entries:
|
|
if proc.name.isdigit() and client_identity(prefix, proc.name, proc_root) is not None:
|
|
found.append(int(proc.name))
|
|
return sorted(found)
|
|
|
|
|
|
def recover(prefix, proc_root=Path('/proc'), window_check=visible_client_window,
|
|
kill=os.kill, sleep=time.sleep, out=sys.stdout):
|
|
if not server_running(proc_root):
|
|
print('recover-stale-client: no darlingserver; normal startup', file=out, flush=True)
|
|
return 0
|
|
if game_running(proc_root):
|
|
print('recover-stale-client: game running; no action', file=out, flush=True)
|
|
return 0
|
|
if window_check():
|
|
print('recover-stale-client: client window present; no action', file=out, flush=True)
|
|
return 0
|
|
identities = {}
|
|
for pid in client_pids(prefix, proc_root):
|
|
start_ticks = client_identity(prefix, pid, proc_root)
|
|
if start_ticks is not None:
|
|
identities[pid] = start_ticks
|
|
if not identities:
|
|
print('recover-stale-client: no stale client processes; no action', file=out, flush=True)
|
|
return 0
|
|
if window_check():
|
|
print('recover-stale-client: client window appeared; no action', file=out, flush=True)
|
|
return 0
|
|
print('recover-stale-client: stale client tree without a window; '
|
|
'terminating %d process(es)' % len(identities), file=out, flush=True)
|
|
for pid, start_ticks in identities.items():
|
|
if client_identity(prefix, pid, proc_root) != start_ticks:
|
|
continue
|
|
try:
|
|
kill(pid, signal.SIGTERM)
|
|
except OSError:
|
|
pass
|
|
sleep(4)
|
|
for pid, start_ticks in identities.items():
|
|
if client_identity(prefix, pid, proc_root) != start_ticks:
|
|
continue
|
|
try:
|
|
kill(pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
return 0
|
|
|
|
|
|
def main():
|
|
if os.environ.get('RCLOL_RECOVER_STALE_CLIENT') != '1':
|
|
print('recover-stale-client: disabled; set RCLOL_RECOVER_STALE_CLIENT=1',
|
|
flush=True)
|
|
return 0
|
|
prefix = target_prefix()
|
|
if prefix is None:
|
|
print('recover-stale-client: unusable DPREFIX; no action',
|
|
file=sys.stderr, flush=True)
|
|
return 1
|
|
return recover(prefix)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|