Files
darling-lol/tools/recover-stale-client.py
T
3AvisD 9194beef2f fix(launcher): harden stale-client recovery
- 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.
2026-10-23 06:08:45 -06:00

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())