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:
3AvisD committed 2026-10-18 12:18:33 -06:00
1 parent f400b86de1
commit 823fa6addc
5 files changed
+513

No files matched your search

+1
View File
@@ -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'
+42
View File
@@ -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:
+207
View File
@@ -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()
+256
View File
@@ -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())
+7
View File
@@ -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}"