mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
Merge pull request #123 from 3AvisD/fix/recover-stale-client
fix(launcher): recover a stale client tree without restarting Darling
This commit is contained in:
9 files changed
+789
No files matched your search
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+183
@@ -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())
|
||||
Executable
+242
@@ -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())
|
||||
@@ -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}"
|
||||
|
||||
Reference in new issue
Block a user