mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
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.
This commit is contained in:
1 parent
0a04d4e16c
commit
9194beef2f
5 files changed
+144
-55
No files matched your search
@@ -115,7 +115,12 @@ If the game or Riot client was closed but `darlingserver` is still running, opt
|
||||
RCLOL_RECOVER_STALE_CLIENT=1 ./scripts/run.sh
|
||||
```
|
||||
|
||||
It stops only a windowless Riot/League/CEF tree, preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then starts the product normally. It does nothing while a game or managed client window is present and fails closed when it cannot inspect X11. See [troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window).
|
||||
It stops only a windowless Riot/League/CEF tree at least 60 seconds old,
|
||||
preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then
|
||||
starts the product normally. It recognizes `LeagueClientUx`, `Riot Client` and
|
||||
`RiotClientServices` windows, does nothing while a game or managed client window
|
||||
is present, and fails closed when X11 properties cannot be read. See
|
||||
[troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window).
|
||||
|
||||
If repeated launches leave several `close-league-client-in-game.py` watchers,
|
||||
opt in to removing the orphaned ones before the new helper starts:
|
||||
|
||||
+5
-4
@@ -80,10 +80,11 @@ for the observed failures, recovery guards and NSURLSession limitations.
|
||||
|
||||
`RCLOL_RECOVER_STALE_CLIENT=1` opts in to partial runtime recovery before
|
||||
launch. If `darlingserver` is alive, no game is running, no managed client
|
||||
window is visible and stale Riot/League/CEF processes remain, the launcher stops
|
||||
only that client tree and reuses the runtime. It preserves `darlingserver`,
|
||||
launchd/mldr, sockets and `.init.pid`; if X11/Xwayland cannot be inspected it
|
||||
does nothing. See
|
||||
window is visible and stale Riot/League/CEF processes at least 60 seconds old
|
||||
remain, the launcher stops only that client tree and reuses the runtime. It
|
||||
recognizes `LeagueClientUx`, `Riot Client` and `RiotClientServices` windows and
|
||||
preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`; if X11/Xwayland
|
||||
properties cannot be inspected it does nothing. See
|
||||
[troubleshooting](TROUBLESHOOTING.md#client-remains-running-without-a-window).
|
||||
|
||||
`RCLOL_CLEAN_STALE_HELPERS=1` runs a preflight before the match helper starts.
|
||||
|
||||
@@ -150,13 +150,15 @@ download is restarted when recovery is enabled. Other desktop environments are
|
||||
not supported by this helper; recovery is off by default.
|
||||
|
||||
For a live Darling runtime with no visible client window but stale Riot/League/
|
||||
CEF processes, `RCLOL_RECOVER_STALE_CLIENT=1` performs a partial recovery before
|
||||
the next launch. It keeps `darlingserver`, launchd/mldr, sockets and `.init.pid`,
|
||||
stops only the stale client process tree, and then lets the normal launcher start
|
||||
the product again. It does nothing while a game process is running or when a
|
||||
managed client window is visible. With X11/Xwayland unavailable it fails closed.
|
||||
This saves the Darling bootstrap but not the Riot Client/CEF startup itself. If
|
||||
no window appears afterward, use a full runtime shutdown/restart.
|
||||
CEF processes at least 60 seconds old, `RCLOL_RECOVER_STALE_CLIENT=1` performs a
|
||||
partial recovery before the next launch. It keeps `darlingserver`, launchd/mldr,
|
||||
sockets and `.init.pid`, stops only the stale client process tree, and then lets
|
||||
the normal launcher start the product again. It recognizes `LeagueClientUx`,
|
||||
`Riot Client` and `RiotClientServices` windows, does nothing while a game process
|
||||
is running or when a managed client window is visible, and fails closed when
|
||||
X11/Xwayland properties are missing or unreadable. This saves the Darling
|
||||
bootstrap but not the Riot Client/CEF startup itself. If no window appears
|
||||
afterward, use a full runtime shutdown/restart.
|
||||
|
||||
### Multiple client-in-game helpers
|
||||
|
||||
|
||||
@@ -24,16 +24,19 @@ class RecoverStaleClientTests(unittest.TestCase):
|
||||
self.root = Path(self.temp.name)
|
||||
self.proc = self.root / 'proc'
|
||||
self.proc.mkdir()
|
||||
(self.proc / 'uptime').write_text('1000 0\n')
|
||||
self.prefix = self.root / '.darling'
|
||||
|
||||
def process(self, pid, comm, argv, env=None, executable=None):
|
||||
def process(self, pid, comm, argv, env=None, executable=None, age=120):
|
||||
folder = self.proc / str(pid)
|
||||
folder.mkdir()
|
||||
(folder / 'comm').write_text(comm + '\n')
|
||||
(folder / 'cmdline').write_bytes(
|
||||
(argv[0] if executable is None else executable).encode() + b'\0' +
|
||||
b''.join(arg.encode() + b'\0' for arg in argv[1:]))
|
||||
(folder / 'stat').write_text(f'{pid} ({comm}) S')
|
||||
ticks = os.sysconf('SC_CLK_TCK')
|
||||
fields = ['S'] + ['0'] * 18 + [str(int((1000 - age) * ticks))]
|
||||
(folder / 'stat').write_text(f'{pid} ({comm}) ' + ' '.join(fields) + '\n')
|
||||
if env is not None:
|
||||
(folder / 'environ').write_bytes(env)
|
||||
return folder
|
||||
@@ -45,11 +48,13 @@ class RecoverStaleClientTests(unittest.TestCase):
|
||||
os.fsencode(prefix / recover_stale_client.SOCK_NAME),
|
||||
)
|
||||
|
||||
def run_recover(self, window=False):
|
||||
def run_recover(self, window=False, on_kill=None):
|
||||
killed = []
|
||||
|
||||
def kill(pid, signum):
|
||||
killed.append((pid, signum))
|
||||
if on_kill is not None:
|
||||
on_kill(pid, signum)
|
||||
|
||||
out = io.StringIO()
|
||||
with patch.object(recover_stale_client, 'visible_client_window',
|
||||
@@ -105,6 +110,52 @@ class RecoverStaleClientTests(unittest.TestCase):
|
||||
self.assertNotIn(10, [pid for pid, _sig in killed])
|
||||
self.assertNotIn(11, [pid for pid, _sig in killed])
|
||||
|
||||
def test_pid_identity_is_rechecked_before_sigkill(self):
|
||||
self.process(10, 'darlingserver', ['darlingserver'])
|
||||
self.process(12, 'mldr', [CLIENT], self.client_env())
|
||||
|
||||
def reuse_pid(pid, signum):
|
||||
if signum == recover_stale_client.signal.SIGTERM:
|
||||
(self.proc / str(pid) / 'cmdline').write_bytes(b'/usr/bin/editor\0')
|
||||
|
||||
result, killed, _output = self.run_recover(on_kill=reuse_pid)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(killed, [(12, recover_stale_client.signal.SIGTERM)])
|
||||
|
||||
def test_young_client_is_preserved(self):
|
||||
self.process(10, 'darlingserver', ['darlingserver'])
|
||||
self.process(12, 'mldr', [CLIENT], self.client_env(),
|
||||
age=recover_stale_client.MIN_AGE_SECONDS - 1)
|
||||
result, killed, output = self.run_recover()
|
||||
self.assertEqual((result, killed), (0, []))
|
||||
self.assertIn('no stale client processes', output)
|
||||
|
||||
def test_missing_net_client_list_is_inconclusive(self):
|
||||
with patch.object(recover_stale_client.subprocess, 'check_output',
|
||||
return_value='_NET_CLIENT_LIST: not found.\n'):
|
||||
self.assertTrue(recover_stale_client.visible_client_window())
|
||||
|
||||
def test_empty_client_list_is_no_window(self):
|
||||
with patch.object(recover_stale_client.subprocess, 'check_output',
|
||||
return_value='_NET_CLIENT_LIST(WINDOW): window id #\n'):
|
||||
self.assertFalse(recover_stale_client.visible_client_window())
|
||||
|
||||
def test_missing_wm_class_is_inconclusive(self):
|
||||
with patch.object(recover_stale_client.subprocess, 'check_output',
|
||||
side_effect=[
|
||||
'_NET_CLIENT_LIST(WINDOW): window id # 0x10\n',
|
||||
'WM_CLASS: not found.\n',
|
||||
]):
|
||||
self.assertTrue(recover_stale_client.visible_client_window())
|
||||
|
||||
def test_riot_client_shell_is_a_visible_window(self):
|
||||
with patch.object(recover_stale_client.subprocess, 'check_output',
|
||||
side_effect=[
|
||||
'_NET_CLIENT_LIST(WINDOW): window id # 0x10\n',
|
||||
'WM_CLASS(STRING) = "RiotClientServices", "RiotClientServices"\n',
|
||||
]):
|
||||
self.assertTrue(recover_stale_client.visible_client_window())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -7,15 +7,17 @@ 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 are still
|
||||
present for this prefix.
|
||||
- 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
|
||||
@@ -23,7 +25,8 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
GAME_NAME = 'LeagueofLegends'
|
||||
WINDOW_CLASSES = ('leagueclientux', 'riot client')
|
||||
WINDOW_CLASSES = ('leagueclientux', 'riot client', 'riotclientservices')
|
||||
MIN_AGE_SECONDS = 60
|
||||
CLIENT_MARKERS = (
|
||||
'/Applications/League of Legends.app',
|
||||
'/Applications/Riot Client.app',
|
||||
@@ -102,8 +105,9 @@ def game_running(proc_root=Path('/proc')):
|
||||
def visible_client_window():
|
||||
"""Return True when a managed client window is present.
|
||||
|
||||
A missing xprop or display is treated as 'cannot prove there is no
|
||||
window' so we do not kill a possibly working client.
|
||||
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(
|
||||
@@ -111,56 +115,71 @@ def visible_client_window():
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return True
|
||||
for raw in listing.split():
|
||||
if not raw.startswith('0x'):
|
||||
continue
|
||||
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."""
|
||||
owner = (prefix / SOCK_NAME).parent.resolve()
|
||||
found = []
|
||||
try:
|
||||
entries = list(proc_root.iterdir())
|
||||
except OSError:
|
||||
return []
|
||||
for proc in entries:
|
||||
if not proc.name.isdigit() or process_state(proc) in ('Z', None):
|
||||
continue
|
||||
try:
|
||||
if proc.stat().st_uid != os.getuid():
|
||||
continue
|
||||
names = command_names(proc)
|
||||
if GAME_NAME in names or any(name in RUNTIME_NAMES for name in names):
|
||||
continue
|
||||
text = command_text(proc)
|
||||
if not any(marker in text for marker in CLIENT_MARKERS):
|
||||
continue
|
||||
try:
|
||||
env = dict(
|
||||
item.split(b'=', 1)
|
||||
for item in (proc / 'environ').read_bytes().split(b'\0')
|
||||
if b'=' in item
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
socket = env.get(SOCK_VARIABLE)
|
||||
if socket is None:
|
||||
continue
|
||||
if Path(os.fsdecode(socket)).parent.resolve() != owner:
|
||||
continue
|
||||
if proc.name.isdigit() and client_identity(prefix, proc.name, proc_root) is not None:
|
||||
found.append(int(proc.name))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return sorted(found)
|
||||
|
||||
|
||||
@@ -175,19 +194,30 @@ def recover(prefix, proc_root=Path('/proc'), window_check=visible_client_window,
|
||||
if window_check():
|
||||
print('recover-stale-client: client window present; no action', file=out, flush=True)
|
||||
return 0
|
||||
pids = client_pids(prefix, proc_root)
|
||||
if not pids:
|
||||
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(pids), file=out, flush=True)
|
||||
for pid in pids:
|
||||
'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 in pids:
|
||||
for pid, start_ticks in identities.items():
|
||||
if client_identity(prefix, pid, proc_root) != start_ticks:
|
||||
continue
|
||||
try:
|
||||
kill(pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
|
||||
Reference in new issue
Block a user