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:
Kalah authored and GitHub committed 2026-10-23 14:19:34 +02:00
commit 92484938b8
9 files changed
+789

No files matched your search

+2
View File
@@ -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'
+25
View File
@@ -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
+18
View File
@@ -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
+35
View File
@@ -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)
+110
View File
@@ -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()
+161
View File
@@ -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()
+183
View File
@@ -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())
+242
View File
@@ -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())
+13
View File
@@ -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}"