mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
fix(launcher): recover a stale client tree without restarting Darling
Darlingserver normally stays alive after the Riot Client or game is closed. If the client window disappears but Riot/League/CEF processes remain, restarting via run.sh reuses a half-dead runtime and can fail to open a window. Add an opt-in RCLOL_RECOVER_STALE_CLIENT=1 preflight. When a darlingserver is alive, no game is running, no managed client window is visible and stale client processes belong to this prefix, stop only that client/CEF tree. Preserve darlingserver, launchd/mldr, sockets and .init.pid, then continue with the normal product launch. The helper fails closed when X11 is unavailable, never touches a running game or a visible client window, and does not patch client binaries, CEF or assets. It saves the Darling bootstrap, but not Riot Client/CEF startup; a full runtime restart remains the fallback if no window appears.
This commit is contained in:
1 parent
a4da79011f
commit
c8dd3886fc
7 files changed
+354
No files matched your search
@@ -31,3 +31,4 @@ jobs:
|
||||
- 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'
|
||||
- run: python -m unittest discover -s tests -p 'test_recover_stale_client.py'
|
||||
@@ -109,6 +109,14 @@ 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, preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then starts the product normally. It does nothing while a game or managed client window is present and fails closed when it cannot inspect X11. See [troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window).
|
||||
|
||||
## Testing
|
||||
|
||||
Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. This project ships no client-side automation, inspection or profiling helpers.
|
||||
|
||||
@@ -78,6 +78,14 @@ 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 remain, the launcher stops
|
||||
only that client tree and reuses the runtime. It preserves `darlingserver`,
|
||||
launchd/mldr, sockets and `.init.pid`; if X11/Xwayland cannot be inspected it
|
||||
does nothing. See
|
||||
[troubleshooting](TROUBLESHOOTING.md#client-remains-running-without-a-window).
|
||||
|
||||
`RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before
|
||||
the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`,
|
||||
GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`),
|
||||
|
||||
@@ -149,6 +149,15 @@ 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, `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 does nothing while a game process is running or when a
|
||||
managed client window is visible. With X11/Xwayland unavailable it fails closed.
|
||||
This saves the Darling bootstrap but not the Riot Client/CEF startup itself. If
|
||||
no window appears afterward, use a full runtime shutdown/restart.
|
||||
|
||||
### Client window stays black after a match
|
||||
|
||||
The League client keeps its Chromium/CEF UI (`LeagueClientUx` and its helpers)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""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.prefix = self.root / '.darling'
|
||||
|
||||
def process(self, pid, comm, argv, env=None, executable=None):
|
||||
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:]))
|
||||
(folder / 'stat').write_text(f'{pid} ({comm}) S')
|
||||
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):
|
||||
killed = []
|
||||
|
||||
def kill(pid, signum):
|
||||
killed.append((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])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/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 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 os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
GAME_NAME = 'LeagueofLegends'
|
||||
WINDOW_CLASSES = ('leagueclientux', 'riot client')
|
||||
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 or display is treated as 'cannot prove there is no
|
||||
window' so we do not kill a possibly working client.
|
||||
"""
|
||||
try:
|
||||
listing = subprocess.check_output(
|
||||
['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return True
|
||||
for raw in listing.split():
|
||||
if not raw.startswith('0x'):
|
||||
continue
|
||||
try:
|
||||
classes = subprocess.check_output(
|
||||
['xprop', '-id', raw, 'WM_CLASS'], text=True, timeout=5
|
||||
).lower()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return True
|
||||
if any(name in classes for name in WINDOW_CLASSES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def client_pids(prefix, proc_root=Path('/proc')):
|
||||
"""Client/CEF PIDs belonging to prefix, excluding Darling and the game."""
|
||||
owner = (prefix / SOCK_NAME).parent.resolve()
|
||||
found = []
|
||||
try:
|
||||
entries = list(proc_root.iterdir())
|
||||
except OSError:
|
||||
return []
|
||||
for proc in entries:
|
||||
if not proc.name.isdigit() or process_state(proc) in ('Z', None):
|
||||
continue
|
||||
try:
|
||||
if proc.stat().st_uid != os.getuid():
|
||||
continue
|
||||
names = command_names(proc)
|
||||
if GAME_NAME in names or any(name in RUNTIME_NAMES for name in names):
|
||||
continue
|
||||
text = command_text(proc)
|
||||
if not any(marker in text for marker in CLIENT_MARKERS):
|
||||
continue
|
||||
try:
|
||||
env = dict(
|
||||
item.split(b'=', 1)
|
||||
for item in (proc / 'environ').read_bytes().split(b'\0')
|
||||
if b'=' in item
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
socket = env.get(SOCK_VARIABLE)
|
||||
if socket is None:
|
||||
continue
|
||||
if Path(os.fsdecode(socket)).parent.resolve() != owner:
|
||||
continue
|
||||
found.append(int(proc.name))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
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
|
||||
pids = client_pids(prefix, proc_root)
|
||||
if not pids:
|
||||
print('recover-stale-client: no stale client processes; no action', file=out, flush=True)
|
||||
return 0
|
||||
print('recover-stale-client: stale client tree without a window; '
|
||||
'terminating %d process(es)' % len(pids), file=out, flush=True)
|
||||
for pid in pids:
|
||||
try:
|
||||
kill(pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
sleep(4)
|
||||
for pid in pids:
|
||||
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,6 +9,12 @@ 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
|
||||
|
||||
Reference in new issue
Block a user