diff --git a/README.md b/README.md index 2e7ebf0..f9154eb 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/SETUP.md b/docs/SETUP.md index c8178a6..cdcdbc8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ee79ad9..571dce4 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -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 diff --git a/tests/test_recover_stale_client.py b/tests/test_recover_stale_client.py index 743907f..14099a0 100644 --- a/tests/test_recover_stale_client.py +++ b/tests/test_recover_stale_client.py @@ -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() diff --git a/tools/recover-stale-client.py b/tools/recover-stale-client.py index 3c29805..66466ce 100755 --- a/tools/recover-stale-client.py +++ b/tools/recover-stale-client.py @@ -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: