diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index d8de01d..20552db 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -21,3 +21,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_system_profiler.py' - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' - 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' diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a6da2e1..778c1bc 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -121,6 +121,48 @@ 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. +### Client window stays black after a match + +The League client keeps its Chromium/CEF UI (`LeagueClientUx` and its helpers) +alive during a match. Riot's own "Close client during game" option closes that UI +on Windows, but under Darling the pre-match instance keeps running: the client the +backend starts when the match ends is a second CEF instance on the same +`--user-data-dir`, and its window never paints. Its DOM and audio work and the +window stays black. Closing the client by hand during the match leaves the same +state for the same reason. + +An opt-in helper closes that UI itself, from the host, and only while a match +runs, so the client that appears afterwards is the only instance: + +```sh +RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +This is not a memory measure: the UI is closed because it competes with the game +for CPU during the match. Measured on a four-core host with a Radeon RX 550, a +practice match reported roughly 120 FPS with the client UI running and roughly +180 FPS with it closed. On that host a local supervisor using the same detection +closed 13 client processes (2027 MiB resident) when a practice match started, and +the client came back as one instance with a painted window when it ended; the +helper described here has not been through a live match yet. Riot's own option is +the equivalent on Windows, so leave either one disabled if the in-client chat is +wanted during a match. + +The helper is off by default and fails closed: it attributes both the game and the +client to the launcher's prefix through the loader's `__mldr_sockpath` (guest +processes do not carry `DPREFIX`), never closes a client younger than 60 seconds +(the one the backend starts after a match), leaves the Riot client, the League +backend and the game untouched, and takes no action when ownership, the +environment or the stat fields cannot be read. The client UI is recognised by the +name of its executable, not by the command line: `LeagueCrashHandler` processes +carry the client UI name in their arguments only, so they are left alone. It +leaves by itself after ten minutes without a client or a match. Diagnose it in one +observation, without waiting for a match: + +```sh +python3 tools/close-league-client-in-game.py --once --dry-run +``` + ### Downloads repeatedly fail with an IPv6 CONNECT_TO error The bundled downloader was observed retrying CDN chunks with: diff --git a/tests/test_close_league_client_in_game.py b/tests/test_close_league_client_in_game.py new file mode 100644 index 0000000..0d774cb --- /dev/null +++ b/tests/test_close_league_client_in_game.py @@ -0,0 +1,207 @@ +"""Exercise the in-match client close with fake /proc entries and mocked signals only.""" +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('in_game', ROOT / 'tools/close-league-client-in-game.py') +in_game = importlib.util.module_from_spec(spec) +spec.loader.exec_module(in_game) + +MAIN = '/Applications/League of Legends.app/Contents/LoL/LeagueClientUx.app/Contents/MacOS/LeagueClientUx' +HELPER = ('/Applications/League of Legends.app/Contents/LoL/LeagueClientUx Helper.app' + '/Contents/MacOS/LeagueClientUx Helper') +GAME = '/Applications/LeagueofLegends.app/Contents/MacOS/LeagueofLegends' +BACKEND = '/Applications/League of Legends.app/Contents/LoL/LeagueClient.app/Contents/MacOS/LeagueClient' +RIOT = '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices' +CRASH_HANDLER = ('/Applications/League of Legends.app/Contents/LoL/LeagueClient.app' + '/Contents/MacOS/LeagueCrashHandler') +# A live crash handler carries the client UI name in its arguments only. +CRASH_ARGUMENTS = ('--attachment=/Applications/League of Legends.app/Contents/LoL/Logs/' + 'LeagueClientUx Logs/2026-10-18T09-49-13_659_LeagueClientUx.log', + '--handshake-fd=52') + + +class InGameCloseTests(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.home = self.root / 'home' + self.home_patch = patch.object(Path, 'home', return_value=self.home) + self.home_patch.start() + self.addCleanup(self.home_patch.stop) + self.prefix = self.home / '.darling' + self.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.prefix)}) + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + + def environment(self, prefix): + if prefix is None: + return b'' + return b'%s=%s\0' % (in_game.SOCK_VARIABLE, os.fsencode(prefix / in_game.SOCK_NAME)) + + def process(self, pid, executable, prefix='own', age=120, environ=None, args=('--flag',)): + folder = self.proc / str(pid) + folder.mkdir() + (folder / 'cmdline').write_bytes(executable.encode() + b'\0' + + b''.join(arg.encode() + b'\0' for arg in args)) + if environ is None: + environ = self.environment(self.prefix if prefix == 'own' else prefix) + (folder / 'environ').write_bytes(environ) + fields = ['S'] + ['0'] * 18 + [str(int((1000 - age) * os.sysconf('SC_CLK_TCK')))] + (folder / 'stat').write_text(str(pid) + ' (LeagueClientUx (helper)) ' + ' '.join(fields)) + return folder + + def client_ui(self, pid, prefix='own', age=120, environ=None): + return self.process(pid, MAIN if pid % 2 else HELPER, prefix=prefix, age=age, environ=environ) + + def run_once(self, dry_run=False): + self.output = io.StringIO() + with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill: + result = in_game.supervise(self.proc, once=True, dry_run=dry_run, out=self.output) + return result, [call.args for call in kill.call_args_list] + + def test_client_ui_without_a_match_is_preserved(self): + self.client_ui(123) + result, calls = self.run_once() + self.assertEqual((result, calls), (0, [])) + self.assertIn('match=absent client_ui=1', self.output.getvalue()) + + def test_running_match_closes_only_the_client_ui(self): + self.client_ui(123) + self.process(124, HELPER) + self.process(125, GAME) + self.process(126, BACKEND) + self.process(127, RIOT) + self.process(128, MAIN, prefix=self.root / 'other-prefix') + result, calls = self.run_once() + self.assertEqual(result, 0) + self.assertEqual(sorted(calls), sorted([(123, in_game.signal.SIGTERM), + (124, in_game.signal.SIGTERM), + (123, in_game.signal.SIGKILL), + (124, in_game.signal.SIGKILL)])) + self.assertIn('closed the client UI for the running match (2 processes: 123,124)', + self.output.getvalue()) + + def test_marker_in_arguments_does_not_make_a_process_client_ui(self): + self.client_ui(123) + self.process(124, CRASH_HANDLER, args=CRASH_ARGUMENTS) + self.process(125, GAME) + result, calls = self.run_once() + self.assertEqual(result, 0) + self.assertEqual(sorted(calls), sorted([(123, in_game.signal.SIGTERM), + (123, in_game.signal.SIGKILL)])) + self.assertIn('closed the client UI for the running match (1 processes: 123)', + self.output.getvalue()) + + def test_game_argument_naming_the_client_ui_is_still_the_game(self): + self.process(125, GAME, args=('--parent=LeagueClientUx',)) + self.process(126, CRASH_HANDLER, args=CRASH_ARGUMENTS) + result, calls = self.run_once() + self.assertEqual((result, calls), (0, [])) + self.assertIn('match=running client_ui=0', self.output.getvalue()) + + def test_ui_identity_rejects_a_related_process_with_the_marker(self): + folder = self.client_ui(123) + self.assertIsNotNone(in_game.ui_identity(self.proc, '123')) + (folder / 'cmdline').write_bytes(CRASH_HANDLER.encode() + b'\0' + + b'\0'.join(a.encode() for a in CRASH_ARGUMENTS) + b'\0') + self.assertIsNone(in_game.ui_identity(self.proc, '123')) + with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill: + in_game.close_ui(self.proc, {'123': 1}) + self.assertEqual(kill.call_args_list, []) + + def test_match_in_another_install_is_preserved(self): + self.client_ui(123) + self.process(125, GAME, prefix=self.root / 'other-prefix') + self.assertEqual(self.run_once()[1], []) + + def test_game_without_an_install_socket_is_preserved(self): + self.client_ui(123) + self.process(125, GAME, environ=b'') + self.assertEqual(self.run_once()[1], []) + + def test_young_client_ui_is_preserved(self): + self.client_ui(123, age=in_game.MIN_AGE_SECONDS - 1) + self.process(125, GAME) + self.assertEqual(self.run_once()[1], []) + + def test_client_ui_of_another_install_is_preserved(self): + self.client_ui(123, prefix=self.root / 'other-prefix') + self.process(125, GAME) + self.assertEqual(self.run_once()[1], []) + + def test_client_ui_without_an_install_socket_is_preserved(self): + self.client_ui(123, environ=b'') + self.process(125, GAME) + self.assertEqual(self.run_once()[1], []) + + def test_missing_prefix_prevents_any_action(self): + self.client_ui(123) + self.process(125, GAME) + with patch.dict(os.environ, {'DPREFIX': 'relative/path'}): + self.assertIsNone(in_game.target_prefix()) + self.assertEqual(self.run_once()[1], []) + + def test_foreign_owned_process_is_ignored(self): + self.client_ui(123) + self.process(125, GAME) + with patch.object(in_game.os, 'getuid', return_value=os.getuid() + 1): + result, calls = self.run_once() + self.assertEqual((result, calls), (0, [])) + + def test_unreadable_environment_prevents_action(self): + self.client_ui(123) + self.process(125, GAME) + original = Path.read_bytes + def read(path): + if path.name == 'environ': + raise PermissionError() + return original(path) + with patch.object(Path, 'read_bytes', read): + self.assertEqual(self.run_once()[1], []) + + def test_malformed_or_missing_age_prevents_action(self): + proc = self.client_ui(123) + self.process(125, GAME) + for value in ('garbage', '123 (worker) S'): + (proc / 'stat').write_text(value) + self.assertEqual(self.run_once()[1], []) + (proc / 'stat').unlink() + self.assertEqual(self.run_once()[1], []) + + def test_dry_run_reports_without_signalling(self): + self.client_ui(123) + self.process(125, GAME) + self.assertEqual(self.run_once(dry_run=True)[1], []) + self.assertIn('would close the client UI (1 processes: 123)', self.output.getvalue()) + + def test_exited_or_reused_processes_are_preserved(self): + folder = self.client_ui(123) + identity = in_game.ui_identity(self.proc, '123') + self.assertIsNotNone(identity) + (folder / 'cmdline').write_bytes(b'/usr/bin/editor\0') + self.assertIsNone(in_game.ui_identity(self.proc, '123')) + with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill: + in_game.close_ui(self.proc, {'123': identity, '124': 1}) + self.assertEqual(kill.call_args_list, []) + + def test_close_decision_covers_limits_and_cooldown(self): + state = {'game': True, 'ui': {'123': 1}} + self.assertEqual(in_game.close_decision(state, 0, 0.0, 100.0), 'close') + self.assertIsNone(in_game.close_decision(state, 0, 90.0, 100.0)) + self.assertEqual(in_game.close_decision(state, in_game.MAX_CLOSES, 0.0, 100.0), 'limit') + self.assertIsNone(in_game.close_decision({'game': False, 'ui': {'123': 1}}, 0, 0.0, 100.0)) + self.assertIsNone(in_game.close_decision({'game': True, 'ui': {}}, 0, 0.0, 100.0)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/close-league-client-in-game.py b/tools/close-league-client-in-game.py new file mode 100755 index 0000000..bdf4508 --- /dev/null +++ b/tools/close-league-client-in-game.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Close the League client UI while a match runs; fail closed. + +Opt-in helper for `run-riot-client.sh` (`RCLOL_CLOSE_CLIENT_IN_GAME=1`). The +League client keeps its Chromium/CEF UI (executable `LeagueClientUx` and its +helpers) alive during a match. Those processes compete with the game for CPU, +and the client the backend starts when the match ends is a second CEF instance +on the same `--user-data-dir` as the pre-match one, so its window never paints: +the DOM and audio work, the window stays black. Riot's own "Close client during +game" option has both outcomes on a Windows client, but under Darling it leaves +the pre-match instance running, so the option reproduces the black window. + +This helper performs the close itself, from the host, while a match is running. +Only then is the client UI expendable, and the client that appears afterwards is +the only instance. It never touches the Riot client, the League backend +(`LeagueClient`) or the game, and it exits by itself when no client is left. + +It is fail closed: it acts only after positively identifying a running game and +the League client UI of its own prefix, and never closes a client younger than +MIN_AGE_SECONDS (the one the backend starts after a match). An unreadable +`/proc` entry, an unreadable environment, an unreadable or malformed stat field, +a process of another install and a missing game all lead to no action. + +The client UI is recognised by the name of its executable, not by the command +line: `LeagueCrashHandler` processes carry the `LeagueClientUx` string in their +arguments (attachment paths under `LeagueClientUx Logs`) and live under the same +`/League` paths, so a wider match would signal a related process. + +Diagnose with `--once` (one observation) or `--once --dry-run` (report only). +""" +import argparse +import math +import os +from pathlib import Path +import signal +import sys +import time + +UX_NAME = 'LeagueClientUx' +GAME_SUFFIX = '/LeagueofLegends' +# DPREFIX is not in the environment of guest processes (checked on a live session), but +# every process the loader starts carries the socket of the install it belongs to. +SOCK_VARIABLE = b'__mldr_sockpath' +SOCK_NAME = '.darlingserver.sock' + +MIN_AGE_SECONDS = 60 # a younger client UI is the one starting after a match +TERM_GRACE = 4.0 # seconds between SIGTERM and SIGKILL +KILL_GRACE = 2.0 +POLL_SECONDS = 5.0 +COOLDOWN_SECONDS = 30.0 # minimum between two closes in the same match +MAX_CLOSES = 3 # give up on a client that keeps reopening during a match +IDLE_SECONDS = 600.0 # leave after this long without a client or a game + + +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 is_client_ui(executable): + """True for the League client UI and its helpers, by executable name. + + The whole command line is not usable: `LeagueCrashHandler` carries the + `LeagueClientUx` string in its arguments (attachment paths under + `LeagueClientUx Logs`) and lives under the same `/League` paths, so only the + file name tells the UI apart from a related process. + """ + name = os.path.basename(executable) + return name == UX_NAME or name.startswith(UX_NAME + ' Helper') + + +def match_state(proc_root, prefix): + """Return {'game': bool, 'ui': {pid: start_ticks}}, or None if not reliable. + + None means the caller must not act. The game and the client UI are both + attributed to `prefix` through the loader's socket path: a process of + another install is not a match and its client is not touched. + """ + state = {'game': False, 'ui': {}} + owner = (prefix / SOCK_NAME).parent.resolve() + try: + 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 + entries = list(proc_root.iterdir()) + except (OSError, ValueError, IndexError): + return None + for proc in entries: + if not proc.name.isdigit(): + continue + try: + if proc.stat().st_uid != os.getuid(): + continue + raw = (proc / 'cmdline').read_bytes() + executable = raw.split(b'\0')[0].decode(errors='replace') + client_ui = is_client_ui(executable) + if not client_ui and not executable.endswith(GAME_SUFFIX): + continue + 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 or Path(os.fsdecode(socket)).parent.resolve() != owner: + continue + if not client_ui: + state['game'] = True + continue + # 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: + continue + state['ui'][proc.name] = start_ticks + except FileNotFoundError: + if proc.exists(): + return None + continue + except (OSError, ValueError, IndexError): + return None + return state + + +def ui_identity(proc_root, pid): + """Start ticks of a live client UI process, or None when it is not one.""" + try: + raw = (proc_root / pid / 'cmdline').read_bytes() + executable = raw.split(b'\0')[0].decode(errors='replace') + if not is_client_ui(executable): + return None + return int((proc_root / pid / 'stat').read_text().rsplit(')', 1)[1].split()[19]) + except (OSError, ValueError, IndexError): + return None + + +def close_ui(proc_root, processes): + """Terminate the given client UI processes; return the pids signalled. + + Each pid is rechecked, including its start time, before every signal: a + process that exited, a reused pid and a process that is no longer a client + UI are all left alone. + """ + closed = set() + for sig, grace in ((signal.SIGTERM, TERM_GRACE), (signal.SIGKILL, KILL_GRACE)): + for pid, start_ticks in processes.items(): + if ui_identity(proc_root, pid) != start_ticks: + continue + try: + os.kill(int(pid), sig) + closed.add(pid) + except OSError: + continue + time.sleep(grace) + return closed + + +def close_decision(state, closes, last_close, now): + """Return 'close', 'limit' or None for one observation.""" + if not state['game'] or not state['ui']: + return None + if closes >= MAX_CLOSES: + return 'limit' + if now - last_close < COOLDOWN_SECONDS: + return None + return 'close' + + +def supervise(proc_root=Path('/proc'), prefix=None, once=False, dry_run=False, out=sys.stdout): + """Watch for a match and close the client UI while it runs.""" + prefix = prefix or target_prefix() + if prefix is None: + print('close-league-client-in-game: unusable DPREFIX: no action', file=out, flush=True) + return 0 + print('close-league-client-in-game: watching %s for a running match; ' + 'opt out with RCLOL_CLOSE_CLIENT_IN_GAME=0' % prefix, file=out, flush=True) + in_game = False + closes = 0 + last_close = 0.0 + limit_reported = False + idle_since = None + while True: + state = match_state(proc_root, prefix) + now = time.monotonic() + if state is None: + print('close-league-client-in-game: unreadable /proc state: no action', + file=out, flush=True) + else: + if state['game'] and not in_game: + in_game = True + closes = 0 + last_close = 0.0 + limit_reported = False + print('close-league-client-in-game: a match started', file=out, flush=True) + elif not state['game'] and in_game: + in_game = False + print('close-league-client-in-game: the match ended; the client can reopen', + file=out, flush=True) + decision = close_decision(state, closes, last_close, now) + if decision == 'limit': + if not limit_reported: + print('close-league-client-in-game: the client reopened %d times in one ' + 'match: no further action' % closes, file=out, flush=True) + limit_reported = True + elif decision == 'close': + pids = sorted(state['ui'], key=int) + if dry_run: + print('close-league-client-in-game: would close the client UI (%d ' + 'processes: %s)' % (len(pids), ','.join(pids)), file=out, flush=True) + else: + closed = close_ui(proc_root, state['ui']) + if closed: + closes += 1 + last_close = time.monotonic() + print('close-league-client-in-game: closed the client UI for the ' + 'running match (%d processes: %s)' + % (len(closed), ','.join(sorted(closed, key=int))), + file=out, flush=True) + idle = not state['game'] and not state['ui'] + if idle and idle_since is None: + idle_since = now + elif not idle: + idle_since = None + if once: + if state is not None: + print('close-league-client-in-game: one observation: match=%s client_ui=%d' + % ('running' if state['game'] else 'absent', len(state['ui'])), + file=out, flush=True) + return 0 + if idle_since is not None and time.monotonic() - idle_since > IDLE_SECONDS: + print('close-league-client-in-game: no client or match for %ds: leaving' + % IDLE_SECONDS, file=out, flush=True) + return 0 + time.sleep(POLL_SECONDS) + return 0 + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--once', action='store_true', + help='one observation and exit (diagnostics)') + parser.add_argument('--dry-run', action='store_true', + help='report what it would close, without signalling anything') + parser.add_argument('--detach', action='store_true', + help='leave the launcher session so a terminal hang-up cannot end it') + args = parser.parse_args() + if args.detach: + try: + os.setsid() + except OSError: + pass + return supervise(once=args.once, dry_run=args.dry_run) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 5fa75a5..83cf108 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -12,6 +12,13 @@ runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi +# Opt-in: close the League client UI while a match runs. It keeps the CPU for the game +# and leaves the client the backend starts when the match ends as the only CEF instance; +# a second instance on the same profile never paints its window, which is the black +# window seen with Riot's own "Close client during game" option. Off by default. +if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/close-league-client-in-game.py" --detach & +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}"