diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index ed7cebc..b1b6e19 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -36,5 +36,7 @@ jobs: - run: python -m unittest discover -s tests -p 'test_indium_address_shadow.py' -v - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' + - run: python -m unittest discover -s tests -p 'test_recover_stale_client.py' + - run: python -m unittest discover -s tests -p 'test_clean_stale_helpers.py' - run: python -m unittest discover -s tests -p 'test_hide_league_client_in_game.py' - run: python -m unittest discover -s tests -p 'test_client_in_game_tricks.py' diff --git a/README.md b/README.md index 689f53f..6dc7f33 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,31 @@ First installation and future Riot updates can still encounter compatibility gap For another region, set `RCLOL_REGION` and `RCLOL_LOCALE` before launch. For example `RCLOL_REGION=NA RCLOL_LOCALE=en_US ./scripts/run.sh`. Linux display selection follows `$DISPLAY`; the legacy launcher fallback is `:1`. +If the game or Riot client was closed but `darlingserver` is still running, opt in to partial recovery before the next launch: + +```sh +RCLOL_RECOVER_STALE_CLIENT=1 ./scripts/run.sh +``` + +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: + +```sh +RCLOL_CLEAN_STALE_HELPERS=1 ./scripts/run.sh +``` + +It only removes helpers of this prefix whose parent is `init`/`systemd` (the +launcher that started them has exited). A helper of a live launcher, another +prefix, the game, Riot/League/CEF, `darlingserver` and the runtime are never +touched. See [troubleshooting](docs/TROUBLESHOOTING.md#multiple-client-in-game-helpers). + For automatic client handling during a match, opt in with one switch: ```sh diff --git a/docs/SETUP.md b/docs/SETUP.md index 548a02f..70e4e71 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -78,6 +78,24 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) 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 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. +It removes `close-league-client-in-game.py` and +`hide-league-client-in-game.py` processes of this prefix whose parent is +`init`/`systemd`, which means the launcher that started them has exited. A +helper of a live launcher, another prefix, the game, Riot/League/CEF and +`darlingserver` are preserved. It fails closed when a command line, +environment, stat entry or parent cannot be read. See +[troubleshooting](TROUBLESHOOTING.md#multiple-client-in-game-helpers). + `RCLOL_CLIENT_IN_GAME_TRICKS=1` opts in to automatic client handling during a match. It reads `lol-user-experience` from the installed client's `LCULocalPreferences.yaml`: with `potatoModeEnabled: true`, it closes the client diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 7869d27..f4a5488 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -149,6 +149,41 @@ ownership or window information cannot be read. A hidden background client or 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 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 + +The close/hide helper is a long-lived watcher. If an earlier launcher exits +while it remains, the next launch can start another copy; list them with: + +```sh +pgrep -af 'close-league-client-in-game.py|hide-league-client-in-game.py' +``` + +To remove the orphaned copies before the next helper starts: + +```sh +RCLOL_CLEAN_STALE_HELPERS=1 ./scripts/run.sh +``` + +Only helpers of the current prefix whose parent is `init`/`systemd` are +terminated. Helpers whose launcher is still alive, other prefixes, the game, +Riot/League/CEF, `darlingserver` and the runtime are never touched. A dry run +is available from the configured tools directory: + +```sh +RCLOL_CLEAN_STALE_HELPERS=1 python3 build/runtime/tools/clean-stale-helpers.py --dry-run +``` + ### Client window stays black after a match The League client keeps its Chromium/CEF UI (`LeagueClientUx` and its helpers) diff --git a/tests/test_clean_stale_helpers.py b/tests/test_clean_stale_helpers.py new file mode 100644 index 0000000..52c0294 --- /dev/null +++ b/tests/test_clean_stale_helpers.py @@ -0,0 +1,110 @@ +"""Exercise orphan-helper cleanup with fake /proc entries and mocked signals.""" +import importlib.util +import io +import os +from pathlib import Path +import signal +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'clean_stale_helpers', ROOT / 'tools/clean-stale-helpers.py') +clean_stale_helpers = importlib.util.module_from_spec(spec) +spec.loader.exec_module(clean_stale_helpers) + +HELPER = '/repo/tools/close-league-client-in-game.py' +HIDE_HELPER = '/repo/tools/hide-league-client-in-game.py' + + +class CleanStaleHelpersTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.prefix = self.root / '.darling' + self.other = self.root / 'other-prefix' + + def parent(self, pid, comm): + folder = self.proc / str(pid) + folder.mkdir(exist_ok=True) + (folder / 'comm').write_text(comm + '\n') + + def process(self, pid, script=HELPER, parent_pid=500, parent_comm='systemd', + prefix='own', argv0='/usr/bin/python3', state='S', + environment=True, arguments=('--detach',)): + folder = self.proc / str(pid) + folder.mkdir() + argv = [argv0, script, *arguments] + (folder / 'cmdline').write_bytes( + b'\0'.join(arg.encode() for arg in argv) + b'\0') + if parent_comm is not None: + self.parent(parent_pid, parent_comm) + fields = [state, str(parent_pid)] + ['0'] * 17 + ['100'] + (folder / 'stat').write_text(f'{pid} (python3) ' + ' '.join(fields) + '\n') + if environment: + prefix = self.prefix if prefix == 'own' else prefix + (folder / 'environ').write_bytes( + b'DPREFIX=' + os.fsencode(prefix) + b'\0') + else: + (folder / 'environ').write_bytes(b'UNRELATED=1\0') + return folder + + def run_clean(self, dry_run=False): + killed = [] + + def kill(pid, signum): + killed.append((pid, signum)) + + out = io.StringIO() + result = clean_stale_helpers.clean( + self.prefix, proc_root=self.proc, kill=kill, + sleep=lambda _seconds: None, out=out, dry_run=dry_run) + return result, killed, out.getvalue() + + def test_removes_only_orphaned_helpers_of_this_prefix(self): + self.process(100, HELPER) + self.process(101, HIDE_HELPER) + self.process(200, HELPER, parent_pid=600, parent_comm='darling') + self.process(201, HELPER, prefix=self.other) + self.process(202, HELPER, argv0='/usr/bin/vim') + self.process(203, HELPER, state='Z') + self.process(204, HELPER, environment=False) + self.process(205, HELPER, parent_pid=700, parent_comm=None) + result, killed, output = self.run_clean() + self.assertEqual(result, 0) + self.assertEqual(sorted(killed), sorted([ + (100, signal.SIGTERM), (100, signal.SIGKILL), + (101, signal.SIGTERM), (101, signal.SIGKILL), + ])) + self.assertIn('removing 2 orphaned helper(s): 100,101', output) + + def test_dry_run_reports_without_signalling(self): + self.process(100) + result, killed, output = self.run_clean(dry_run=True) + self.assertEqual((result, killed), (0, [])) + self.assertIn('would remove 1 orphaned helper(s): 100', output) + + def test_no_orphans_is_a_noop(self): + self.process(200, parent_pid=600, parent_comm='darling') + result, killed, output = self.run_clean() + self.assertEqual((result, killed), (0, [])) + self.assertIn('no orphaned helpers', output) + + def test_relative_helper_prefix_is_preserved(self): + self.process(100, prefix='relative-prefix') + result, killed, output = self.run_clean() + self.assertEqual((result, killed), (0, [])) + self.assertIn('no orphaned helpers', output) + + def test_launcher_runs_cleanup_before_starting_a_helper(self): + source = (ROOT / 'tools/run-riot-client.sh').read_text() + self.assertIn('RCLOL_CLEAN_STALE_HELPERS', source) + self.assertLess(source.index('clean-stale-helpers.py'), + source.index('close-league-client-in-game.py')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_recover_stale_client.py b/tests/test_recover_stale_client.py new file mode 100644 index 0000000..14099a0 --- /dev/null +++ b/tests/test_recover_stale_client.py @@ -0,0 +1,161 @@ +"""Test the opt-in partial client-tree recovery with fake /proc entries.""" +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'recover_stale_client', ROOT / 'tools/recover-stale-client.py') +recover_stale_client = importlib.util.module_from_spec(spec) +spec.loader.exec_module(recover_stale_client) + +GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends' +CLIENT = '/Users/Shared/Riot Games/Riot Client.app/Contents/MacOS/RiotClientServices' + + +class RecoverStaleClientTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + 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, 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:])) + 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 + + def client_env(self, prefix=None): + prefix = self.prefix if prefix is None else prefix + return b'%s=%s\0' % ( + recover_stale_client.SOCK_VARIABLE, + os.fsencode(prefix / recover_stale_client.SOCK_NAME), + ) + + 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', + return_value=window): + result = recover_stale_client.recover( + self.prefix, proc_root=self.proc, window_check=lambda: window, + kill=kill, sleep=lambda _seconds: None, out=out) + return result, killed, out.getvalue() + + def test_no_darlingserver_does_nothing(self): + self.process(10, 'mldr', ['/sbin/launchd']) + result, killed, output = self.run_recover() + self.assertEqual((result, killed), (0, [])) + self.assertIn('no darlingserver', output) + + def test_game_is_never_touched(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', [GAME]) + result, killed, output = self.run_recover() + self.assertEqual((result, killed), (0, [])) + self.assertIn('game running', output) + + def test_visible_window_does_nothing(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', [CLIENT], self.client_env()) + result, killed, output = self.run_recover(window=True) + self.assertEqual((result, killed), (0, [])) + self.assertIn('client window present', output) + + def test_stale_client_is_killed_but_runtime_is_preserved(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', ['/sbin/launchd']) + self.process(12, 'mldr', [CLIENT], self.client_env()) + result, killed, output = self.run_recover() + self.assertEqual(result, 0) + self.assertEqual([pid for pid, _sig in killed], [12, 12]) + self.assertIn('stale client tree', output) + + def test_other_prefix_and_game_are_preserved(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', [CLIENT], self.client_env(self.root / 'other')) + self.process(12, 'mldr', [GAME], self.client_env()) + result, killed, output = self.run_recover() + self.assertEqual((result, killed), (0, [])) + self.assertIn('game running', output) + + def test_runtime_process_names_are_not_killed(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', ['/sbin/launchd']) + self.process(12, 'mldr', [CLIENT], self.client_env()) + result, killed, _output = self.run_recover() + self.assertEqual(result, 0) + 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/clean-stale-helpers.py b/tools/clean-stale-helpers.py new file mode 100755 index 0000000..a01d49a --- /dev/null +++ b/tools/clean-stale-helpers.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Remove orphaned client-in-game helpers from an earlier launcher run. + +The close/hide helpers are long-lived watch processes. A launcher can end +while they remain, and a later run starts another helper, so several copies +watch the same prefix. This opt-in preflight removes only helpers that belong +to this prefix and whose parent is init/systemd: the launcher that started +them has already exited and reparented them. It never signals a helper whose +parent is still alive, the current launcher, darlingserver, the game, +Riot/League/CEF, or a process of another prefix. + +It fails closed: an unreadable command line, environment, stat entry or +parent leaves that process alone. Enable it with +RCLOL_CLEAN_STALE_HELPERS=1; the launcher runs it before starting its own +helper. +""" + +import argparse +import os +import signal +import sys +import time +from pathlib import Path + +HELPER_NAMES = ('close-league-client-in-game.py', + 'hide-league-client-in-game.py') +REAPER_NAMES = ('init', 'systemd') +TERM_GRACE = 3.0 + + +def target_prefix(): + """The prefix this launcher owns, or None when it cannot be determined.""" + value = os.environ.get('DPREFIX', str(Path.home() / '.darling')) + prefix = Path(value).expanduser() + return prefix if prefix.is_absolute() else None + + +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 parent_is_reaper(ppid, proc_root): + """True when the parent is PID 1 or a systemd/init user manager.""" + if ppid == 1: + return True + try: + comm = (proc_root / str(ppid) / 'comm').read_text().strip() + except OSError: + return False + return comm in REAPER_NAMES + + +def helper_identity(proc, prefix, proc_root): + """Return the start ticks of an orphaned helper of prefix, or None. + + The script name must be an exact argument, and the executable must be a + Python interpreter. This avoids matching an editor or a log viewer that + happens to have the helper name in its arguments. DPREFIX is inherited by + the helper from run-riot-client.sh; a missing or different value belongs + to another install and is preserved. + """ + try: + if proc.stat().st_uid != os.getuid(): + return None + raw = (proc / 'cmdline').read_bytes() + argv = [part.decode(errors='replace') for part in raw.split(b'\0') if part] + if len(argv) < 2 or not os.path.basename(argv[0]).startswith(('python', 'pypy')): + return None + if not any(os.path.basename(arg) in HELPER_NAMES for arg in argv[1:]): + return None + environment = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + raw_prefix = environment.get(b'DPREFIX') + if raw_prefix is None: + return None + helper_prefix = Path(os.fsdecode(raw_prefix)).expanduser() + if not helper_prefix.is_absolute(): + return None + helper_prefix = helper_prefix.resolve() + if helper_prefix != prefix: + return None + + info = process_info(proc) + if info is None or info[0] == 'Z': + return None + if not parent_is_reaper(info[1], proc_root): + return None + return info[2] + except FileNotFoundError: + return None + except (OSError, RuntimeError, ValueError): + return None + + +def clean(prefix, proc_root=Path('/proc'), kill=os.kill, sleep=time.sleep, + out=sys.stdout, dry_run=False): + """Terminate orphaned helpers for prefix; return 0 on every normal path.""" + if prefix is None or not prefix.is_absolute(): + print('clean-stale-helpers: unusable DPREFIX: no action', file=out, flush=True) + return 1 + try: + prefix = prefix.expanduser().resolve() + except (OSError, RuntimeError): + print('clean-stale-helpers: unusable DPREFIX: no action', file=out, flush=True) + return 1 + try: + entries = list(proc_root.iterdir()) + except OSError: + print('clean-stale-helpers: unreadable /proc: no action', file=out, flush=True) + return 0 + + helpers = {} + for proc in entries: + if not proc.name.isdigit() or int(proc.name) == os.getpid(): + continue + start_ticks = helper_identity(proc, prefix, proc_root) + if start_ticks is not None: + helpers[int(proc.name)] = start_ticks + if not helpers: + print('clean-stale-helpers: no orphaned helpers; nothing to do', + file=out, flush=True) + return 0 + + pids = sorted(helpers) + action = 'would remove' if dry_run else 'removing' + print('clean-stale-helpers: %s %d orphaned helper(s): %s' + % (action, len(pids), ','.join(str(pid) for pid in pids)), + file=out, flush=True) + if dry_run: + return 0 + + for pid, start_ticks in helpers.items(): + proc = proc_root / str(pid) + if helper_identity(proc, prefix, proc_root) != start_ticks: + continue + try: + kill(pid, signal.SIGTERM) + except OSError: + continue + sleep(TERM_GRACE) + for pid, start_ticks in helpers.items(): + proc = proc_root / str(pid) + if helper_identity(proc, prefix, proc_root) != start_ticks: + continue + try: + kill(pid, signal.SIGKILL) + except OSError: + pass + return 0 + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--prefix', type=Path, + help='prefix whose helpers should be checked') + parser.add_argument('--dry-run', action='store_true', + help='report what would be removed without signalling') + args = parser.parse_args() + if os.environ.get('RCLOL_CLEAN_STALE_HELPERS') != '1': + print('clean-stale-helpers: disabled; set RCLOL_CLEAN_STALE_HELPERS=1', + flush=True) + return 0 + prefix = args.prefix or target_prefix() + if prefix is None or not prefix.is_absolute(): + print('clean-stale-helpers: unusable DPREFIX: no action', + file=sys.stderr, flush=True) + return 1 + return clean(prefix, dry_run=args.dry_run) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/recover-stale-client.py b/tools/recover-stale-client.py new file mode 100755 index 0000000..66466ce --- /dev/null +++ b/tools/recover-stale-client.py @@ -0,0 +1,242 @@ +#!/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()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 611c810..bf1d067 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -9,9 +9,22 @@ if [ "${RCLOL_CORE_DUMPS:-0}" != 1 ]; then fi runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) . "$runner_dir/runtime-paths.sh" +# Opt-in partial recovery: if Darling is alive but a previous client tree is +# windowless, stop only that client/CEF tree and reuse the runtime. Never +# touches the game, darlingserver or the runtime sockets. +if [ "${RCLOL_RECOVER_STALE_CLIENT:-0}" = 1 ]; then + python3 "$runner_dir/recover-stale-client.py" +fi if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi +# Opt-in cleanup of helpers left by an earlier launcher. This runs before the +# current helper is started. It removes only helpers of this prefix whose +# parent is init/systemd, so a helper of a live launcher is not touched. +if [ "${RCLOL_CLEAN_STALE_HELPERS:-0}" = 1 ]; then + python3 "$runner_dir/clean-stale-helpers.py" --prefix "$prefix_dir" || + echo 'clean-stale-helpers: cleanup failed; continuing' >&2 +fi # The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN # AAAA lookups are suppressed; set this to 0 to retain the upstream resolver. export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}"