mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
fix(launcher): close the League client UI while a match runs
The client does not come back properly after a match when its own "Close client during game" option is enabled. The UI (LeagueClientUx and its helpers) is a Chromium/CEF instance and, 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 a match leaves the same state for the same reason. RCLOL_CLOSE_CLIENT_IN_GAME=1 opts in to closing that UI from the host, and only while a match runs, so the client that appears afterwards is the only instance. The UI also competes with the game for CPU: on a four-core host with a Radeon RX 550 a practice match reported roughly 120 FPS with the UI running and roughly 180 FPS with it closed. This is not the helper-killing memory optimization the RAM section warns against, and the Riot client, the League backend and the game are never touched. The helper fails closed. It attributes the game and the client to the launcher's prefix through the loader's __mldr_sockpath, since guest processes do not carry DPREFIX (checked on a live session). It never closes a client younger than 60 seconds (the one the backend starts after a match), preserves a process of another installation or owner, acts only when the environment and the stat fields can be read, gives up after three closes in one match, and leaves after ten minutes without a client or a match. It is off by default. Tests use fake /proc entries and mock every signal.
This commit is contained in:
1 parent
d24f26ee63
commit
bc8fa71a7f
5 files changed
+513
No files matched your search
@@ -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'
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Executable
+256
@@ -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())
|
||||
@@ -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}"
|
||||
|
||||
Reference in new issue
Block a user