From 97c78c2679718cc1b35eff39c8ab537f686828df Mon Sep 17 00:00:00 2001 From: ekisu Date: Mon, 19 Oct 2026 19:19:55 -0300 Subject: [PATCH 01/13] perf(indium): cache buffer address keys in CPU memory --- patches/metal--deps--indium.patch | 49 ++++++- tests/fixtures/indium-address-shadow.cpp | 175 +++++++++++++++++++++++ tests/test_indium_address_shadow.py | 50 +++++++ 3 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/indium-address-shadow.cpp create mode 100644 tests/test_indium_address_shadow.py diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index f3c149f..f6c73e5 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -289,7 +289,7 @@ index b7ffcce..5b1e84c 100644 }; }; diff --git a/private-include/indium/command-encoder.private.hpp b/private-include/indium/command-encoder.private.hpp -index c3b1b93..3dab2ce 100644 +index c3b1b93..6b8783d 100644 --- a/private-include/indium/command-encoder.private.hpp +++ b/private-include/indium/command-encoder.private.hpp @@ -1,6 +1,7 @@ @@ -300,7 +300,7 @@ index c3b1b93..3dab2ce 100644 #include #include #include -@@ -17,117 +18,295 @@ +@@ -17,117 +18,330 @@ #include @@ -529,6 +529,40 @@ index c3b1b93..3dab2ce 100644 + struct AddressPool { + std::shared_ptr chunk; + size_t used = 0; ++ ++ // Two shader stages are live in the render encoder. Cache their small ++ // immutable tables in CPU memory, not in the GPU-mapped scratch chunk. ++ // This is only a read cache: misses retain the original byte comparison. ++ struct Shadow { ++ bool valid = false; ++ uint32_t offset; ++ VkDeviceSize range; ++ size_t count; ++ std::array addresses; ++ }; ++ std::array shadows; ++ size_t nextShadow = 0; ++ ++ bool sameAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) const { ++ for (const auto& shadow : shadows) { ++ // A table's last eight bytes can be padding. If a new layout ++ // reads that padding, only the mapped bytes define the old key. ++ if (shadow.valid && shadow.offset == offset && shadow.range == range && count <= shadow.count) ++ return !memcmp(shadow.addresses.data(), addresses, count * sizeof(uint64_t)); ++ } ++ return !memcmp(static_cast(chunk->contents()) + offset, addresses, count * sizeof(uint64_t)); ++ } ++ ++ void rememberAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) { ++ if (count > 64) return; ++ auto& shadow = shadows[nextShadow]; ++ nextShadow = (nextShadow + 1) % shadows.size(); ++ shadow.offset = offset; ++ shadow.range = range; ++ shadow.count = count; ++ memcpy(shadow.addresses.data(), addresses, count * sizeof(uint64_t)); ++ shadow.valid = true; ++ } + }; - if (functionResources.buffers.size() > 0) { @@ -583,17 +617,18 @@ index c3b1b93..3dab2ce 100644 + if (bindingReuseEnabled() && previous && addressPool.chunk && previous->range == tableSize && + previous->offset <= chunkSize && tableSize <= chunkSize - previous->offset && + previous->buffer == static_cast(addressPool.chunk.get())->buffer() && -+ !memcmp(static_cast(addressPool.chunk->contents()) + previous->offset, -+ addresses.data(), addresses.size() * sizeof(uint64_t))) ++ addressPool.sameAddresses(previous->offset, previous->range, addresses.data(), addresses.size())) + return *previous; + size_t tableOffset = (addressPool.used + alignment - 1) & ~(alignment - 1); + if (!addressPool.chunk || tableOffset + tableSize > chunkSize) { + addressPool.chunk = privateDevice->acquireScratchChunk(); ++ for (auto& shadow : addressPool.shadows) shadow.valid = false; + // we need to keep the chunk alive until the operation is completed + keepAliveBuffers.push_back(addressPool.chunk); + tableOffset = 0; + } + memcpy(static_cast(addressPool.chunk->contents()) + tableOffset, addresses.data(), addresses.size() * sizeof(uint64_t)); ++ if (previous) addressPool.rememberAddresses(static_cast(tableOffset), tableSize, addresses.data(), addresses.size()); + addressPool.used = tableOffset + tableSize; + // plain pointer: a shared_ptr cast here was an atomic reference count round trip per stage per draw + auto* privateAddressBuffer = static_cast(addressPool.chunk.get()); @@ -654,7 +689,7 @@ index c3b1b93..3dab2ce 100644 descSet.descriptorCount = 1; descSet.pBufferInfo = &info; } -@@ -136,17 +315,27 @@ namespace Indium { +@@ -136,17 +350,27 @@ namespace Indium { auto& bindingInfo = funcInfo.bindings[j]; if (bindingInfo.type == Iridium::BindingType::Texture) { @@ -691,7 +726,7 @@ index c3b1b93..3dab2ce 100644 auto& descSet = writeDescSet.emplace_back(); descSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; descSet.dstSet = descriptorSets[i]; -@@ -156,19 +345,21 @@ namespace Indium { +@@ -156,19 +380,21 @@ namespace Indium { descSet.descriptorCount = 1; descSet.pImageInfo = &info; } else if (bindingInfo.type == Iridium::BindingType::Sampler) { @@ -724,7 +759,7 @@ index c3b1b93..3dab2ce 100644 info.sampler = privateSampler->sampler(); auto& descSet = writeDescSet.emplace_back(); -@@ -188,10 +379,4 @@ namespace Indium { +@@ -188,10 +414,4 @@ namespace Indium { return descriptorSets; }; diff --git a/tests/fixtures/indium-address-shadow.cpp b/tests/fixtures/indium-address-shadow.cpp new file mode 100644 index 0000000..4d10ebd --- /dev/null +++ b/tests/fixtures/indium-address-shadow.cpp @@ -0,0 +1,175 @@ +// CPU-only buffer/Vulkan shims; the scratch, pool and table helper are extracted +// from the authoritative component patch, not copied into this fixture. +#include +#include +#include +#include +#include +#include +#include +#include +#include +static size_t allocationCalls = 0; +void* operator new(size_t size) { + ++allocationCalls; + if (auto* p = std::malloc(size ? size : 1)) return p; + throw std::bad_alloc(); +} +void operator delete(void* p) noexcept { std::free(p); } +void operator delete(void* p, size_t) noexcept { std::free(p); } +using VkBuffer = uint64_t; +using VkDeviceSize = uint64_t; +constexpr VkBuffer VK_NULL_HANDLE = 0; +namespace Iridium { enum class BindingType { Buffer, Texture, Sampler }; } +static bool enabled = true; +bool bindingReuseEnabled() { return enabled; } +void logUnboundResource(const char*, size_t, size_t, size_t) {} +struct Buffer { virtual ~Buffer() = default; virtual void* contents() = 0; }; +struct PrivateBuffer : Buffer { + uint64_t id, address; + size_t reads = 0; + std::vector bytes; + PrivateBuffer(uint64_t id, uint64_t address) : id(id), address(address), bytes(256*1024, 0xa5) {} + void* contents() override { ++reads; return bytes.data(); } + VkBuffer buffer() const { return id; } + uint64_t gpuAddress() const { return address; } +}; +struct PrivateDevice { + size_t chunks = 0; + uint64_t fallback = 0xf00d; + uint64_t fallbackBufferAddress() { return fallback; } + std::shared_ptr acquireScratchChunk() { ++chunks; return std::make_shared(chunks, chunks << 20); } +}; +struct FunctionResources { std::vector, size_t>> buffers; }; +struct Resolved { PrivateBuffer* value = nullptr; }; +struct ResolvedFunctionResources { std::vector buffers; }; +struct Binding { Iridium::BindingType type; size_t index; }; +struct FunctionInfo { std::vector bindings; }; + +/* ACTUAL_PATCHED_HELPER */ + +struct Check { + AddressPool pool; + std::shared_ptr device = std::make_shared(); + std::vector> keep; + std::array tables{}; + Check() { keep.reserve(8); } + PrivateBuffer* mapped() { return static_cast(pool.chunk.get()); } + void run(size_t stage, const FunctionResources& resources, const FunctionInfo& info, + bool reuse, const ResolvedFunctionResources* resolved = nullptr, bool previous = true) { + auto old = tables[stage]; + auto used = pool.used, chunks = device->chunks, calls = allocationCalls; + auto table = makeBufferAddressTable(resources, info, resolved, device, keep, pool, + previous ? &tables[stage] : nullptr); + // Only the existing DescriptorScratch overflow and new scratch chunk + // may allocate (make_shared control/object plus the chunk byte vector). + assert(allocationCalls - calls == (info.bindings.size() > 64 ? 1u : 0u) + + (device->chunks - chunks) * 2); + assert((table.buffer == old.buffer && table.offset == old.offset && pool.used == used) == reuse); + assert(table.range == ((std::count_if(info.bindings.begin(), info.bindings.end(), + [](const Binding& b) { return b.type == Iridium::BindingType::Buffer; }) * 8 + 15) & ~size_t(15))); + assert(keep.size() == device->chunks); + size_t index = 0; + for (const auto& binding : info.bindings) { + if (binding.type != Iridium::BindingType::Buffer) continue; + PrivateBuffer* buffer = nullptr; + if (resolved && binding.index < resolved->buffers.size()) buffer = resolved->buffers[binding.index].value; + else if (binding.index < resources.buffers.size()) buffer = dynamic_cast(resources.buffers[binding.index].first.get()); + uint64_t expected = buffer ? buffer->gpuAddress() + resources.buffers[binding.index].second : device->fallback; + uint64_t actual; + memcpy(&actual, mapped()->bytes.data() + table.offset + index++ * 8, 8); + assert(actual == expected); + } + tables[stage] = table; + } +}; +FunctionInfo info(size_t count, size_t start = 0) { + FunctionInfo result; + for (size_t i = 0; i < count; ++i) result.bindings.push_back({Iridium::BindingType::Buffer, start+i}); + return result; +} +int main() { + // Two 64-key entries: +1080 bytes per pool on the tested 64-bit ABI. + static_assert(sizeof(AddressPool) == sizeof(std::shared_ptr) + sizeof(size_t) + 1080); + FunctionResources r; + auto resource = std::make_shared(100, 0x1000); + for (size_t i = 0; i < 150; ++i) r.buffers.push_back({resource, i*16}); + Check p; + auto f = info(3); + p.run(0, r, f, false); + auto accesses = p.mapped()->reads; + p.run(0, r, f, true); assert(p.mapped()->reads == accesses); + r.buffers[100].second++; p.run(0, r, f, true); + auto equivalent = std::make_shared(101, 0x0ff0); + r.buffers[0] = {equivalent, 16}; p.run(0, r, f, true); + r.buffers[1].second++; p.run(0, r, f, false); + auto mixed = f; + mixed.bindings.push_back({Iridium::BindingType::Texture, 999}); + mixed.bindings.push_back({Iridium::BindingType::Sampler, 999}); + p.run(0, r, mixed, true); + p.run(1, r, info(4, 20), false); + accesses = p.mapped()->reads; + p.run(0, r, f, true); p.run(1, r, info(4, 20), true); + assert(p.mapped()->reads == accesses); // both interleaved stages hit CPU keys + for (size_t i = 0; i < 5; ++i) { + r.buffers[20].second++; p.run(1, r, info(4, 20), false); + } + accesses = p.mapped()->reads; + p.run(0, r, f, true); + assert(p.mapped()->reads == accesses + 1); // eviction retains mapped fallback + + Check padding; + padding.run(0, r, info(1), false); + r.buffers[1] = {resource, uint64_t(0xa5a5a5a5a5a5a5a5ULL - resource->gpuAddress())}; + accesses = padding.mapped()->reads; + padding.run(0, r, info(2), true); // odd -> even reads real padding + assert(padding.mapped()->reads == accesses + 1); + r.buffers[1].second++; padding.run(0, r, info(2), false); + accesses = padding.mapped()->reads; + padding.run(0, r, info(1), true); // even -> odd needs only cached prefix + assert(padding.mapped()->reads == accesses); + padding.run(0, r, info(3), false); + + Check large; + for (size_t count : {size_t(64), size_t(65), size_t(150), size_t(32767), size_t(32768)}) { + large.run(0, r, info(count), false); + large.run(0, r, info(count), true); + } + r.buffers[64].second++; large.run(0, r, info(65), false); + large.run(1, r, info(8), false, nullptr, false); + large.run(1, r, info(8), true); + + Check missing; + FunctionResources empty; + missing.run(0, empty, info(2, 999), false); + missing.run(0, empty, info(2, 999), true); + missing.device->fallback++; missing.run(0, empty, info(2, 999), false); + ResolvedFunctionResources resolved; + resolved.buffers.resize(r.buffers.size()); resolved.buffers[0].value = resource.get(); + missing.run(1, r, info(1), false, &resolved); + missing.run(1, r, info(1), true, &resolved); + resolved.buffers[0].value = nullptr; + missing.run(1, r, info(1), false, &resolved); + + Check roll; + roll.run(0, r, info(2), false); roll.run(1, r, info(2, 3), false); + auto oldChunk = roll.pool.chunk; + roll.pool.used = 256*1024; + r.buffers[0].second++; roll.run(0, r, info(2), false); + assert(roll.pool.chunk != oldChunk && roll.keep.size() == 2); + // Old offset 256 must not supply a stale key for untouched new-chunk bytes. + roll.tables[1] = {roll.tables[0].buffer, 256, 16}; + roll.run(1, r, info(2, 3), false); roll.run(1, r, info(2, 3), true); + enabled = false; roll.run(1, r, info(2, 3), false); enabled = true; + + Check guard; + guard.run(0, r, info(0), false); guard.run(0, r, info(0), true); + auto id = guard.tables[0].buffer; + guard.tables[0] = {id, 256*1024, 0}; guard.run(0, r, info(0), true); + guard.tables[0] = {id, 256*1024+1, 0}; guard.run(0, r, info(0), false); + accesses = guard.mapped()->reads; + guard.tables[0] = {id, 256*1024-8, 16}; guard.run(0, r, info(1), false); + assert(guard.mapped()->reads == accesses + 1); // write only; no invalid read + guard.tables[0].buffer += 100; guard.run(0, r, info(1), false); + guard.tables[0].range = 32; guard.run(0, r, info(1), false); +} diff --git a/tests/test_indium_address_shadow.py b/tests/test_indium_address_shadow.py new file mode 100644 index 0000000..1a8e886 --- /dev/null +++ b/tests/test_indium_address_shadow.py @@ -0,0 +1,50 @@ +"""Compile the shipped address-table helper against an inert CPU-only fixture.""" +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +HEADER = 'private-include/indium/command-encoder.private.hpp' + + +def address_helper(): + patch = (ROOT / 'patches/metal--deps--indium.patch').read_text() + marker = f'diff --git a/{HEADER} b/{HEADER}\n' + section = patch.split(marker, 1)[1].split('\ndiff --git ', 1)[0] + # Extract each postimage hunk separately: never silently join a source gap. + hunks = re.split(r'(?m)^@@ [^\n]*\n', section)[1:] + postimages = [''.join(line[1:] for line in hunk.splitlines(keepends=True) + if line.startswith((' ', '+'))) for hunk in hunks] + + def region(start, end): + matches = [text[text.index(start):text.index(end, text.index(start))] + for text in postimages if start in text and end in text] + if len(matches) != 1: + raise AssertionError('expected one complete patched region: ' + start) + return matches[0] + + return (region(' template class DescriptorScratch', + ' // Per-encoder binding tables') + + region('\tstruct AddressPool {', '\n\ttemplate')) + + +@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required') +class AddressShadowChecks(unittest.TestCase): + def test_interleaving_fallback_padding_rollover_large_keys_and_allocations(self): + fixture = (ROOT / 'tests/fixtures/indium-address-shadow.cpp').read_text() + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) + source = path / 'check.cpp' + source.write_text(fixture.replace('/* ACTUAL_PATCHED_HELPER */', address_helper())) + build = subprocess.run(['c++', '-std=c++17', '-O1', str(source), '-o', str(path/'check')], + capture_output=True, text=True) + self.assertEqual(build.returncode, 0, build.stderr) + result = subprocess.run([str(path/'check')], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == '__main__': + unittest.main() From 634803a30a28c53c8e4d02045acfcecbf26c8a27 Mon Sep 17 00:00:00 2001 From: ekisu Date: Tue, 20 Oct 2026 00:34:25 -0300 Subject: [PATCH 02/13] test(indium): run accepted address shadow regression in CI --- .github/workflows/source-check.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 3e91493..d8c96b5 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -16,6 +16,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' + - name: Require C++ regression compiler + run: | + command -v c++ + c++ --version - run: python scripts/validate.py - run: python tools/riot-input-offline-check.py - run: python tools/riot-launcher-offline-check.py @@ -28,5 +32,6 @@ jobs: - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' - 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_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' From 899576cbbcca0d7bfeebfb5a9401ef06f8fc188d Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 12:15:35 -0600 Subject: [PATCH 03/13] feat(launcher): hide the League client UI while a match runs Add an opt-in alternative to closing LeagueClientUx: hide the managed LeagueClientUx and Riot Client X11 windows while the game process runs and show them again when it exits. The client stays open; the game window is never touched. Detection is attributed to the launcher prefix through __mldr_sockpath and fails closed when proc, ownership or window state is unreadable. The helper requires X11/Xwayland, restores the window on end, signal or error, and is mutually exclusive with RCLOL_CLOSE_CLIENT_IN_GAME=1. Document the flag, compliance scope and limits. A local 30-second test on an i5-3470/RX 550 reduced summed client/CEF CPU from 120.7% to 93.4%; this is a CPU-bound-host measurement, not a universal FPS claim. No client binaries, CEF, assets or anti-cheat are modified. --- .github/workflows/source-check.yml | 1 + README.md | 8 + docs/SETUP.md | 8 + docs/TROUBLESHOOTING.md | 49 ++++ tests/test_hide_league_client_in_game.py | 165 +++++++++++++ tools/hide-league-client-in-game.py | 295 +++++++++++++++++++++++ tools/run-riot-client.sh | 11 + 7 files changed, 537 insertions(+) create mode 100644 tests/test_hide_league_client_in_game.py create mode 100755 tools/hide-league-client-in-game.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index a866b9d..afc8de7 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -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_hide_league_client_in_game.py' diff --git a/README.md b/README.md index b6144f7..97774de 100644 --- a/README.md +++ b/README.md @@ -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 you want to keep the client open during matches, opt in to hiding its window instead of closing it: + +```sh +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +The helper detects the match, hides the `LeagueClientUx`/`Riot Client` X11 windows, and restores them when the game ends. It does not close the client, patch CEF or touch the game window. It is off by default, requires X11/Xwayland, and `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence. See [troubleshooting](docs/TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) for the measured CPU effect and caveats. + ## 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. diff --git a/docs/SETUP.md b/docs/SETUP.md index 0c0d39b..a83e8b7 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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_HIDE_CLIENT_IN_GAME=1` opts in to hiding the managed `LeagueClientUx` +and `Riot Client` X11 windows while a match runs, then showing them when the +game process ends. It keeps the client open and is mutually exclusive with +`RCLOL_CLOSE_CLIENT_IN_GAME=1` (close takes precedence). It is off by default, +requires X11/Xwayland, and does not patch client binaries, CEF or the game. See +[the troubleshooting measurement](TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) +for the measured CPU effect and limitations. + `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_*`), diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 13a2f7b..2125d45 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,6 +192,55 @@ observation, without waiting for a match: python3 tools/close-league-client-in-game.py --once --dry-run ``` +### Keep the client open but hide it during a match + +`LeagueClientUx` can consume a noticeable amount of CPU while the client stays +open during a match. An opt-in alternative to closing it hides the managed +client window instead: + +```sh +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +The launcher detects when a match starts, unmaps the `LeagueClientUx` and +`Riot Client` X11 windows, and maps them again when the game process ends. The +client is not closed, the League backend and Riot client keep running, and the +game window is never hidden. `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence +when both flags are set. + +The helper is off by default and window-management only: it does not patch +client binaries, CEF, anti-cheat or DRM. It attributes the game to this prefix +through the loader's `__mldr_sockpath`, and fails closed when ownership, proc +state, environment or window information cannot be read. It requires an X11 or +Xwayland desktop; on a native Wayland window manager without Xwayland it cannot +find the windows. + +On a CPU-bound host the effect appears after CEF has been hidden for a few +seconds. In a local 30-second test on an i5-3470 with a Radeon RX 550, hiding +`LeagueClientUx` reduced the summed CPU of the matched client/CEF processes from +120.7% to 93.4% (about 23%, or 27 percentage points, of one core); showing the +window returned it to 119.9%. This is not a universal FPS or percentage claim: +a game that is GPU-bound, a client with no active UI work, or a different +desktop may show less benefit. The hidden window can also need a moment to +repaint after it is shown. + +`RCLOL_CLOSE_CLIENT_IN_GAME=1` remains the more aggressive option when in-client +chat during the match is not needed. Configure one of the two, not both: + +```sh +# Keep the client open, hide its windows while playing +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh + +# Or close the client UI while playing +RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +Diagnose the match detector without changing any window: + +```sh +python3 tools/hide-league-client-in-game.py --once +``` + ### Riot reports NO INTERNET FOUND while the host is online Older base runtimes ship Google DNS servers (`8.8.8.8` and `8.8.4.4`), which diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py new file mode 100644 index 0000000..22740fe --- /dev/null +++ b/tests/test_hide_league_client_in_game.py @@ -0,0 +1,165 @@ +"""Exercise the in-match client window hide with fake /proc entries and X11.""" +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( + 'hide_in_game', ROOT / 'tools/hide-league-client-in-game.py') +hide_in_game = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hide_in_game) + +GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends' +MAIN = '/Applications/League of Legends.app/Contents/LoL/LeagueClientUx.app/Contents/MacOS/LeagueClientUx' +RIOT = '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices' + + +class FakeX11: + def __init__(self): + self.unmapped = [] + self.shown = [] + + def unmap(self, window): + self.unmapped.append(window) + + def show(self, window): + self.shown.append(window) + + +class HideClientInGameTests(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.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.prefix)}) + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + + def environment(self, prefix): + return b'%s=%s\0' % ( + hide_in_game.SOCK_VARIABLE, + os.fsencode(prefix / hide_in_game.SOCK_NAME), + ) + + def process(self, pid, executable=GAME, prefix='own', 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) + (folder / 'stat').write_text(f'{pid} (game) S') + return folder + + def test_game_running_for_own_prefix(self): + self.process(123) + self.assertTrue(hide_in_game.game_running(self.prefix, self.proc)) + + def test_no_game_is_false(self): + self.process(123, executable=MAIN) + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_game_of_another_prefix_is_false(self): + self.process(123, prefix=self.root / 'other') + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_game_without_socket_is_false(self): + self.process(123, environ=b'') + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_unreadable_environment_is_unknown(self): + folder = self.process(123) + original = Path.read_bytes + + def read(path): + if path.name == 'environ': + raise PermissionError() + return original(path) + + with patch.object(Path, 'read_bytes', read): + self.assertIsNone(hide_in_game.game_running(self.prefix, self.proc)) + + def test_target_windows_ignores_game_class(self): + with patch.object(hide_in_game, 'xprop_root', + return_value=['0x10', '0x20', '0x30']), \ + patch.object(hide_in_game, 'window_class', + side_effect=[ + 'wm_class(string) = "leagueclientux"', + 'wm_class(string) = "leagueoflegends"', + 'wm_class(string) = "riot client"', + ]): + self.assertEqual(hide_in_game.target_windows(), [0x10, 0x30]) + + def test_hides_then_shows_on_match_end(self): + states = iter([True, True, False]) + x11 = FakeX11() + output = io.StringIO() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xabc], + state_source=lambda _prefix: next(states), + sleep=lambda _seconds: None, + out=output, + ) + watcher.run(max_steps=3) + self.assertEqual(x11.unmapped, [0xabc]) + self.assertEqual(x11.shown, [0xabc]) + self.assertIn('hidden 0xabc', output.getvalue()) + self.assertIn('the match ended', output.getvalue()) + self.assertIn('shown 0xabc', output.getvalue()) + + def test_unreadable_state_restores_the_window(self): + states = iter([True, None]) + x11 = FakeX11() + output = io.StringIO() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xdef], + state_source=lambda _prefix: next(states), + sleep=lambda _seconds: None, + out=output, + ) + watcher.run(max_steps=2) + self.assertEqual(x11.unmapped, [0xdef]) + self.assertEqual(x11.shown, [0xdef]) + self.assertIn('unreadable /proc state', output.getvalue()) + + def test_no_game_does_not_touch_windows(self): + x11 = FakeX11() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xabc], + state_source=lambda _prefix: False, + sleep=lambda _seconds: None, + out=io.StringIO(), + ) + watcher.run(max_steps=1) + self.assertEqual(x11.unmapped, []) + self.assertEqual(x11.shown, []) + + def test_main_is_disabled_by_default(self): + with patch.dict(os.environ, { + 'RCLOL_HIDE_CLIENT_IN_GAME': '', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '', + }): + self.assertEqual(hide_in_game.main([]), 0) + + def test_close_mode_takes_precedence(self): + with patch.dict(os.environ, { + 'RCLOL_HIDE_CLIENT_IN_GAME': '1', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '1', + }): + self.assertEqual(hide_in_game.main([]), 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py new file mode 100755 index 0000000..2762c46 --- /dev/null +++ b/tools/hide-league-client-in-game.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""Opt-in: hide the League client UI while a match is running. + +`RCLOL_HIDE_CLIENT_IN_GAME=1` is an alternative to closing the client UI with +`RCLOL_CLOSE_CLIENT_IN_GAME=1`. It hides the managed X11 windows for +`LeagueClientUx` and `Riot Client`, and shows them again when the game process +exits. The Riot client, League backend, CEF helpers and game keep running. + +This is launcher/window management only. It does not patch client binaries, +CEF, anti-cheat or DRM, and it never hides the game window. It is off by +default, fails closed, and is intended for X11/Xwayland desktops. + +The game is attributed to this prefix through the loader's +`__mldr_sockpath`, because guest processes do not carry `DPREFIX`. If proc, +ownership or window information cannot be read, no window is touched. + +Diagnose one observation with: + + python3 tools/hide-league-client-in-game.py --once + +Enable it for a launcher run with: + + RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +""" + +import argparse +import ctypes +import os +import re +import signal +import subprocess +import sys +import time +from pathlib import Path + +GAME_NAME = 'LeagueofLegends' +SOCK_VARIABLE = b'__mldr_sockpath' +SOCK_NAME = '.darlingserver.sock' +POLL_SECONDS = 2.0 +TARGET_CLASSES = ('leagueclientux', 'riot client') + + +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_state(proc): + try: + return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0] + except (OSError, IndexError): + return 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 game_running(prefix, proc_root=Path('/proc')): + """True/False for a game of this prefix; None when /proc is unreliable. + + The game executable is matched by its exact file name. The process must + carry the loader socket of this prefix, so a game from another install is + never considered. Any unreadable candidate makes the result None (fail + closed). + """ + owner = (prefix / SOCK_NAME).parent.resolve() + try: + entries = list(proc_root.iterdir()) + except OSError: + return None + 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 + if GAME_NAME not in command_names(proc): + 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: + continue + if Path(os.fsdecode(socket)).parent.resolve() != owner: + continue + return True + except FileNotFoundError: + if proc.exists(): + return None + continue + except (OSError, ValueError): + return None + return False + + +def xprop_root(): + try: + listing = subprocess.check_output( + ['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return [] + return re.findall(r'0x[0-9a-fA-F]+', listing) + + +def window_class(window): + try: + prop = subprocess.check_output( + ['xprop', '-id', window, 'WM_CLASS'], text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return '' + return prop.lower() + + +def target_windows(): + """Managed client windows only; the game window is never selected.""" + found = [] + for window in xprop_root(): + classes = window_class(window) + if any(name in classes for name in TARGET_CLASSES): + found.append(int(window, 16)) + return sorted(set(found)) + + +class X11: + def __init__(self): + self.lib = ctypes.CDLL('libX11.so.6') + self.lib.XOpenDisplay.restype = ctypes.c_void_p + self.lib.XOpenDisplay.argtypes = [ctypes.c_char_p] + self.lib.XUnmapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + self.lib.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + self.lib.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] + self.lib.XCloseDisplay.argtypes = [ctypes.c_void_p] + error_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) + self._error_handler = error_type(lambda _display, _event: 0) + self.lib.XSetErrorHandler.argtypes = [error_type] + self.lib.XSetErrorHandler.restype = ctypes.c_void_p + self.lib.XSetErrorHandler(self._error_handler) + self.display = self.lib.XOpenDisplay(None) + if not self.display: + raise RuntimeError('cannot open DISPLAY') + + def unmap(self, window): + self.lib.XUnmapWindow(self.display, window) + self.lib.XSync(self.display, 0) + + def show(self, window): + self.lib.XMapRaised(self.display, window) + self.lib.XSync(self.display, 0) + + def close(self): + self.lib.XCloseDisplay(self.display) + + +class Watcher: + """Hide while a match is running, restore on end, uncertainty or exit.""" + + def __init__(self, prefix, x11, window_source=target_windows, + state_source=game_running, sleep=time.sleep, out=sys.stdout): + self.prefix = prefix + self.x11 = x11 + self.window_source = window_source + self.state_source = state_source + self.sleep = sleep + self.out = out + self.hidden = set() + self.reported_no_window = False + + def _show_all(self, reason): + if not self.hidden: + return + print(f'hide-league-client-in-game: {reason}; showing the client', file=self.out, flush=True) + for window in sorted(self.hidden): + try: + self.x11.show(window) + print(f'hide-league-client-in-game: shown 0x{window:x}', file=self.out, flush=True) + except Exception as error: + print(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}', + file=sys.stderr, flush=True) + self.hidden.clear() + + def _hide_new(self): + windows = self.window_source() + if not windows and not self.hidden and not self.reported_no_window: + print('hide-league-client-in-game: no managed client window; not hiding', + file=self.out, flush=True) + self.reported_no_window = True + for window in windows: + if window in self.hidden: + continue + try: + self.x11.unmap(window) + self.hidden.add(window) + print(f'hide-league-client-in-game: hidden 0x{window:x}', file=self.out, flush=True) + except Exception as error: + print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', + file=sys.stderr, flush=True) + + def run(self, max_steps=None): + steps = 0 + while True: + state = self.state_source(self.prefix) + if state is None: + self._show_all('unreadable /proc state') + elif state: + if not self.hidden and self.reported_no_window: + self.reported_no_window = False + self._hide_new() + else: + self._show_all('the match ended') + self.reported_no_window = False + steps += 1 + if max_steps is not None and steps >= max_steps: + return 0 + self.sleep(POLL_SECONDS) + + def restore(self): + self._show_all('exiting') + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--detach', action='store_true', + help='leave the launcher session so a terminal hang-up cannot end it') + parser.add_argument('--once', action='store_true', + help='one game observation and exit (no window changes)') + args = parser.parse_args(argv) + + if os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': + print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence', + file=sys.stderr, flush=True) + return 0 + if os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': + print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1', + flush=True) + return 0 + prefix = target_prefix() + if prefix is None: + print('hide-league-client-in-game: unusable DPREFIX: no action', + file=sys.stderr, flush=True) + return 1 + if args.once: + state = game_running(prefix) + print(f'hide-league-client-in-game: match={"unreadable" if state is None else "running" if state else "absent"}', + flush=True) + return 0 + if args.detach: + try: + os.setsid() + except OSError: + pass + try: + x11 = X11() + except Exception as error: + print(f'hide-league-client-in-game: cannot open X11: {error}', + file=sys.stderr, flush=True) + return 1 + + watcher = Watcher(prefix, x11) + + def stop(_signum, _frame): + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + print('hide-league-client-in-game: watching %s for a running match; ' + 'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True) + try: + watcher.run() + except KeyboardInterrupt: + pass + finally: + watcher.restore() + x11.close() + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 767294b..88dc083 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -19,6 +19,17 @@ fi if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then python3 "$runner_dir/close-league-client-in-game.py" --detach & fi +# Opt-in alternative for users who keep the client open during a match: hide +# the managed client windows instead of closing the UI. It never touches the +# League backend, the Riot client or the game window; it restores the window +# when the game process ends. Close and hide are mutually exclusive. +if [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then + if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then + echo 'RCLOL_HIDE_CLIENT_IN_GAME ignored: RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence' >&2 + else + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + fi +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}" From 521527eb21e1571c6844c3fa319fdbb7c4a7118b Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 12:32:03 -0600 Subject: [PATCH 04/13] fix(launcher): address hide-client review findings - associate target windows with the selected prefix through the guest PID, X11 _NET_WM_PID, NSpid and the loader socket; fail closed when ownership is unknown; - let --once run without either opt-in gate, matching the documented diagnostic; - route SIGHUP through the restore handler; - cache non-empty WM_CLASS results per window and prune stale entries; - cover unreadable /proc, host PID mapping and prefix ownership in tests. --- tests/test_hide_league_client_in_game.py | 67 ++++++++++++- tools/hide-league-client-in-game.py | 120 ++++++++++++++++++++--- 2 files changed, 167 insertions(+), 20 deletions(-) diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 22740fe..87f4e05 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -88,6 +88,43 @@ class HideClientInGameTests(unittest.TestCase): with patch.object(Path, 'read_bytes', read): self.assertIsNone(hide_in_game.game_running(self.prefix, self.proc)) + def test_unreadable_process_directory_is_unknown(self): + class UnreadableProc: + def iterdir(self): + raise OSError() + + self.assertIsNone(hide_in_game.game_running(self.prefix, UnreadableProc())) + + def test_host_socket_for_guest_uses_nspid(self): + host = self.proc / '999' + host.mkdir() + (host / 'status').write_text('Name:\tCrBrowserMain\nNSpid:\t999\t5877\n') + (host / 'environ').write_bytes(self.environment(self.prefix)) + self.assertEqual( + hide_in_game.host_socket_for_guest(5877, self.proc), + os.fsencode(self.prefix / hide_in_game.SOCK_NAME), + ) + + def test_window_belongs_to_prefix(self): + socket = os.fsencode(self.prefix / hide_in_game.SOCK_NAME) + with patch.object(hide_in_game, 'window_pid', return_value=5877), \ + patch.object(hide_in_game, 'host_socket_for_guest', return_value=socket): + self.assertTrue(hide_in_game.window_belongs_to_prefix('0x10', self.prefix)) + self.assertFalse(hide_in_game.window_belongs_to_prefix( + '0x10', self.root / 'other')) + + def test_once_runs_without_opt_in(self): + output = io.StringIO() + with patch.dict(os.environ, { + 'RCLOL_HIDE_CLIENT_IN_GAME': '', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '', + }), patch.object(hide_in_game, 'target_prefix', + return_value=self.prefix), \ + patch.object(hide_in_game, 'game_running', return_value=True), \ + patch.object(hide_in_game.sys, 'stdout', output): + self.assertEqual(hide_in_game.main(['--once']), 0) + self.assertIn('match=running', output.getvalue()) + def test_target_windows_ignores_game_class(self): with patch.object(hide_in_game, 'xprop_root', return_value=['0x10', '0x20', '0x30']), \ @@ -96,8 +133,28 @@ class HideClientInGameTests(unittest.TestCase): 'wm_class(string) = "leagueclientux"', 'wm_class(string) = "leagueoflegends"', 'wm_class(string) = "riot client"', - ]): - self.assertEqual(hide_in_game.target_windows(), [0x10, 0x30]) + ]), \ + patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True): + self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10, 0x30]) + + def test_target_windows_requires_this_prefix(self): + with patch.object(hide_in_game, 'xprop_root', + return_value=['0x10', '0x20']), \ + patch.object(hide_in_game, 'window_class', + return_value='wm_class(string) = "leagueclientux"'), \ + patch.object(hide_in_game, 'window_belongs_to_prefix', + side_effect=[True, False]): + self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10]) + + def test_target_windows_caches_non_empty_classes(self): + cache = {} + with patch.object(hide_in_game, 'xprop_root', return_value=['0x10']), \ + patch.object(hide_in_game, 'window_class', + return_value='wm_class(string) = "leagueclientux"') as classes, \ + patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True): + self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10]) + self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10]) + self.assertEqual(classes.call_count, 1) def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) @@ -105,7 +162,7 @@ class HideClientInGameTests(unittest.TestCase): output = io.StringIO() watcher = hide_in_game.Watcher( self.prefix, x11, - window_source=lambda: [0xabc], + window_source=lambda _prefix, _cache: [0xabc], state_source=lambda _prefix: next(states), sleep=lambda _seconds: None, out=output, @@ -123,7 +180,7 @@ class HideClientInGameTests(unittest.TestCase): output = io.StringIO() watcher = hide_in_game.Watcher( self.prefix, x11, - window_source=lambda: [0xdef], + window_source=lambda _prefix, _cache: [0xdef], state_source=lambda _prefix: next(states), sleep=lambda _seconds: None, out=output, @@ -137,7 +194,7 @@ class HideClientInGameTests(unittest.TestCase): x11 = FakeX11() watcher = hide_in_game.Watcher( self.prefix, x11, - window_source=lambda: [0xabc], + window_source=lambda _prefix, _cache: [0xabc], state_source=lambda _prefix: False, sleep=lambda _seconds: None, out=io.StringIO(), diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 2762c46..91264d8 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -117,23 +117,112 @@ def xprop_root(): return re.findall(r'0x[0-9a-fA-F]+', listing) -def window_class(window): +def xprop_value(window, prop): try: - prop = subprocess.check_output( - ['xprop', '-id', window, 'WM_CLASS'], text=True, timeout=5 + return subprocess.check_output( + ['xprop', '-id', window, prop], text=True, timeout=5 ) except (OSError, subprocess.SubprocessError): return '' - return prop.lower() -def target_windows(): - """Managed client windows only; the game window is never selected.""" +def window_class(window): + return xprop_value(window, 'WM_CLASS').lower() + + +def window_pid(window): + match = re.search(r'=\s*(\d+)', xprop_value(window, '_NET_WM_PID')) + return int(match.group(1)) if match else None + + +def _socket_from_process(proc): + try: + environment = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + except (OSError, ValueError): + return None + return environment.get(SOCK_VARIABLE) + + +def host_socket_for_guest(guest_pid, proc_root=Path('/proc')): + """Find the host process for a guest X11 PID and return its loader socket. + + Darling's X11 window stores the guest PID in _NET_WM_PID, while the + process visible in /proc is the host loader. NSpid carries both IDs. The + direct /proc/ case is checked too for a native process. + """ + direct = proc_root / str(guest_pid) + if direct.exists(): + socket = _socket_from_process(direct) + if socket is not None: + return socket + sockets = set() + try: + entries = list(proc_root.iterdir()) + except OSError: + return None + for proc in entries: + if not proc.name.isdigit(): + continue + try: + status = (proc / 'status').read_text() + except OSError: + continue + for line in status.splitlines(): + if not line.startswith('NSpid:'): + continue + if str(guest_pid) not in line.split()[1:]: + continue + socket = _socket_from_process(proc) + if socket is not None: + sockets.add(socket) + return sockets.pop() if len(sockets) == 1 else None + + +def window_belongs_to_prefix(window, prefix): + pid = window_pid(window) + if pid is None: + return False + socket = host_socket_for_guest(pid) + if socket is None: + return False + try: + return Path(os.fsdecode(socket)).parent.resolve() == (prefix / SOCK_NAME).parent.resolve() + except OSError: + return False + + +def target_windows(prefix, class_cache=None): + """Managed client windows for this prefix; the game window is never selected. + + WM_CLASS is cached only when non-empty. The cache is pruned when a window + leaves _NET_CLIENT_LIST; empty results are retried because a class can be + set after the window appears. + """ + current = [] + for raw in xprop_root(): + try: + current.append((int(raw, 16), raw)) + except ValueError: + continue + current_ids = {window for window, _raw in current} + if class_cache is not None: + for stale in set(class_cache) - current_ids: + del class_cache[stale] found = [] - for window in xprop_root(): - classes = window_class(window) - if any(name in classes for name in TARGET_CLASSES): - found.append(int(window, 16)) + for window, raw in current: + classes = class_cache.get(window) if class_cache is not None else None + if not classes: + classes = window_class(raw) + if classes and class_cache is not None: + class_cache[window] = classes + if not classes or not any(name in classes for name in TARGET_CLASSES): + continue + if window_belongs_to_prefix(raw, prefix): + found.append(window) return sorted(set(found)) @@ -179,6 +268,7 @@ class Watcher: self.sleep = sleep self.out = out self.hidden = set() + self.class_cache = {} self.reported_no_window = False def _show_all(self, reason): @@ -195,7 +285,7 @@ class Watcher: self.hidden.clear() def _hide_new(self): - windows = self.window_source() + windows = self.window_source(self.prefix, self.class_cache) if not windows and not self.hidden and not self.reported_no_window: print('hide-league-client-in-game: no managed client window; not hiding', file=self.out, flush=True) @@ -242,11 +332,11 @@ def main(argv=None): help='one game observation and exit (no window changes)') args = parser.parse_args(argv) - if os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': + if not args.once and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence', file=sys.stderr, flush=True) return 0 - if os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': + if not args.once and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1', flush=True) return 0 @@ -277,8 +367,8 @@ def main(argv=None): def stop(_signum, _frame): raise KeyboardInterrupt - signal.signal(signal.SIGTERM, stop) - signal.signal(signal.SIGINT, stop) + for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + signal.signal(signum, stop) print('hide-league-client-in-game: watching %s for a running match; ' 'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True) try: From 8de6bceeae0c8115a3343d7f7acdc545f5bd656f Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 14:56:03 -0600 Subject: [PATCH 05/13] feat(launcher): select client-in-game mode from preferences Add RCLOL_CLIENT_IN_GAME_TRICKS=1 as the practical launcher switch. It reads lol-user-experience from LCULocalPreferences.yaml: - potatoModeEnabled false or unreadable: no action; - closeLeagueClientDuringGame true: close the client UI; - closeLeagueClientDuringGame false: hide the client windows. The existing close and hide switches remain low-level options when the automatic switch is not set. The hide helper accepts the master switch, and the resolver plus preference cases are covered by tests and documentation. The local .desktop entry was validated with the master switch; the installed preferences select hide on this machine. --- .github/workflows/source-check.yml | 1 + README.md | 14 +++-- docs/SETUP.md | 17 +++--- docs/TROUBLESHOOTING.md | 56 +++++++++---------- tests/test_client_in_game_tricks.py | 69 ++++++++++++++++++++++++ tests/test_hide_league_client_in_game.py | 20 ++++++- tools/client-in-game-tricks.py | 57 ++++++++++++++++++++ tools/hide-league-client-in-game.py | 19 ++++--- tools/run-riot-client.sh | 39 ++++++++------ 9 files changed, 231 insertions(+), 61 deletions(-) create mode 100644 tests/test_client_in_game_tricks.py create mode 100755 tools/client-in-game-tricks.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index afc8de7..780121a 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -32,3 +32,4 @@ jobs: - 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_hide_league_client_in_game.py' + - run: python -m unittest discover -s tests -p 'test_client_in_game_tricks.py' diff --git a/README.md b/README.md index 97774de..51a3cf4 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,21 @@ 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 you want to keep the client open during matches, opt in to hiding its window instead of closing it: +For automatic client handling during a match, opt in with one switch: ```sh -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh ``` -The helper detects the match, hides the `LeagueClientUx`/`Riot Client` X11 windows, and restores them when the game ends. It does not close the client, patch CEF or touch the game window. It is off by default, requires X11/Xwayland, and `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence. See [troubleshooting](docs/TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) for the measured CPU effect and caveats. +It reads `lol-user-experience` from the installed League client's +`LCULocalPreferences.yaml`. When `potatoModeEnabled` is true, it closes the +client UI if `closeLeagueClientDuringGame` is true; otherwise it hides the +`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game +ends. With client optimizations disabled or unreadable preferences it does +nothing. The `RCLOL_CLOSE_CLIENT_IN_GAME` and `RCLOL_HIDE_CLIENT_IN_GAME` +low-level switches remain available when the automatic switch is not set. See +[troubleshooting](docs/TROUBLESHOOTING.md#automatic-client-handling-during-a-match) +for the measured effect and caveats. ## Testing diff --git a/docs/SETUP.md b/docs/SETUP.md index a83e8b7..2679892 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -78,12 +78,17 @@ 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_HIDE_CLIENT_IN_GAME=1` opts in to hiding the managed `LeagueClientUx` -and `Riot Client` X11 windows while a match runs, then showing them when the -game process ends. It keeps the client open and is mutually exclusive with -`RCLOL_CLOSE_CLIENT_IN_GAME=1` (close takes precedence). It is off by default, -requires X11/Xwayland, and does not patch client binaries, CEF or the game. See -[the troubleshooting measurement](TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) +`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 +UI when `closeLeagueClientDuringGame: true`, otherwise it hides the managed +`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game +ends. With client optimizations disabled or preferences unreadable it does +nothing. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and +`RCLOL_HIDE_CLIENT_IN_GAME=1` switches remain available when the automatic +switch is not set. The feature is off by default, requires X11/Xwayland, and +does not patch client binaries, CEF or the game. See +[the troubleshooting measurement](TROUBLESHOOTING.md#automatic-client-handling-during-a-match) for the measured CPU effect and limitations. `RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2125d45..0c37ba5 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,30 +192,39 @@ observation, without waiting for a match: python3 tools/close-league-client-in-game.py --once --dry-run ``` -### Keep the client open but hide it during a match +### Automatic client handling during a match -`LeagueClientUx` can consume a noticeable amount of CPU while the client stays -open during a match. An opt-in alternative to closing it hides the managed -client window instead: +A single opt-in switch reads the client's own preferences and chooses the mode: ```sh -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh ``` -The launcher detects when a match starts, unmaps the `LeagueClientUx` and -`Riot Client` X11 windows, and maps them again when the game process ends. The -client is not closed, the League backend and Riot client keep running, and the -game window is never hidden. `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence -when both flags are set. +It reads `lol-user-experience` from the installed client's +`LCULocalPreferences.yaml`: -The helper is off by default and window-management only: it does not patch -client binaries, CEF, anti-cheat or DRM. It attributes the game to this prefix -through the loader's `__mldr_sockpath`, and fails closed when ownership, proc -state, environment or window information cannot be read. It requires an X11 or -Xwayland desktop; on a native Wayland window manager without Xwayland it cannot -find the windows. +| Client preference | Launcher action | +|---|---| +| `potatoModeEnabled: false` or unreadable | no client-in-game helper | +| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI | +| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows | -On a CPU-bound host the effect appears after CEF has been hidden for a few +The hide path unmaps the `LeagueClientUx` and `Riot Client` X11 windows and maps +them again when the game process ends. The client is not closed, the League +backend and Riot client keep running, and the game window is never hidden. The +close path uses the UI-close helper described above. + +The low-level `RCLOL_HIDE_CLIENT_IN_GAME=1` and +`RCLOL_CLOSE_CLIENT_IN_GAME=1` switches remain available when the automatic +switch is not set. All modes are off by default. The helpers are +window-management only: they do not patch client binaries, CEF, anti-cheat or +DRM. They attribute the game to this prefix through the loader's +`__mldr_sockpath`, and fail closed when ownership, proc state, environment or +window information cannot be read. The hide helper requires an X11 or Xwayland +desktop; on a native Wayland window manager without Xwayland it cannot find the +windows. + +On a CPU-bound host the hide effect appears after CEF has been hidden for a few seconds. In a local 30-second test on an i5-3470 with a Radeon RX 550, hiding `LeagueClientUx` reduced the summed CPU of the matched client/CEF processes from 120.7% to 93.4% (about 23%, or 27 percentage points, of one core); showing the @@ -224,16 +233,9 @@ a game that is GPU-bound, a client with no active UI work, or a different desktop may show less benefit. The hidden window can also need a moment to repaint after it is shown. -`RCLOL_CLOSE_CLIENT_IN_GAME=1` remains the more aggressive option when in-client -chat during the match is not needed. Configure one of the two, not both: - -```sh -# Keep the client open, hide its windows while playing -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh - -# Or close the client UI while playing -RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh -``` +With client optimizations enabled and close-during-game enabled, the close +helper remains the more aggressive option when in-client chat during the match +is not needed. Diagnose the match detector without changing any window: diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py new file mode 100644 index 0000000..6477571 --- /dev/null +++ b/tests/test_client_in_game_tricks.py @@ -0,0 +1,69 @@ +"""Select the automatic client-in-game mode from LCULocalPreferences.yaml.""" +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( + 'client_in_game_tricks', ROOT / 'tools/client-in-game-tricks.py') +tricks = importlib.util.module_from_spec(spec) +spec.loader.exec_module(tricks) + + +class ClientInGameTricksTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name) / 'LCULocalPreferences.yaml' + + def write(self, optimization=None, close=None): + lines = ['lol-user-experience:', ' data:'] + if optimization is not None: + lines.append(f' potatoModeEnabled: {optimization}') + if close is not None: + lines.append(f' closeLeagueClientDuringGame: {close}') + self.path.write_text('\n'.join(lines) + '\n') + + def test_close_when_both_preferences_are_true(self): + self.write(True, True) + self.assertEqual(tricks.mode(self.path), 'close') + + def test_hide_when_optimizations_are_on_and_close_is_false(self): + self.write(True, False) + self.assertEqual(tricks.mode(self.path), 'hide') + + def test_hide_when_close_preference_is_missing(self): + self.write(True) + self.assertEqual(tricks.mode(self.path), 'hide') + + def test_none_when_optimizations_are_off(self): + self.write(False, True) + self.assertEqual(tricks.mode(self.path), 'none') + + def test_none_when_optimizations_are_missing_or_file_is_unreadable(self): + self.write(None, True) + self.assertEqual(tricks.mode(self.path), 'none') + self.assertEqual(tricks.mode(self.path.with_name('missing.yaml')), 'none') + + def test_last_duplicate_value_wins_and_quotes_are_accepted(self): + self.path.write_text( + 'potatoModeEnabled: false\n' + 'closeLeagueClientDuringGame: false\n' + 'potatoModeEnabled: "true"\n' + 'closeLeagueClientDuringGame: "true"\n') + self.assertEqual(tricks.mode(self.path), 'close') + + def test_main_prints_mode(self): + self.write(True, False) + output = io.StringIO() + with patch('sys.stdout', output): + self.assertEqual(tricks.main([str(self.path)]), 0) + self.assertEqual(output.getvalue().strip(), 'hide') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 87f4e05..95be689 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -5,7 +5,7 @@ import os from pathlib import Path import tempfile import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch ROOT = Path(__file__).resolve().parents[1] spec = importlib.util.spec_from_file_location( @@ -217,6 +217,24 @@ class HideClientInGameTests(unittest.TestCase): }): self.assertEqual(hide_in_game.main([]), 0) + def test_master_tricks_switch_enables_hide(self): + class FakeX11: + def close(self): + pass + + watcher = Mock() + with patch.dict(os.environ, { + 'RCLOL_CLIENT_IN_GAME_TRICKS': '1', + 'RCLOL_HIDE_CLIENT_IN_GAME': '', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '', + }), patch.object(hide_in_game, 'target_prefix', + return_value=self.prefix), \ + patch.object(hide_in_game, 'X11', return_value=FakeX11()), \ + patch.object(hide_in_game, 'Watcher', return_value=watcher): + self.assertEqual(hide_in_game.main([]), 0) + watcher.run.assert_called_once_with() + watcher.restore.assert_called_once_with() + if __name__ == '__main__': unittest.main() diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py new file mode 100755 index 0000000..bdc8d6b --- /dev/null +++ b/tools/client-in-game-tricks.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Resolve the client-in-game mode from League's local preferences. + +The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads +`lol-user-experience` from LCULocalPreferences.yaml: + +- potatoModeEnabled must be true (client optimizations enabled); +- closeLeagueClientDuringGame true -> close the client UI while a match runs; +- closeLeagueClientDuringGame false -> hide the client windows instead; +- missing/unreadable preferences -> no action (none). + +This helper only selects a mode. It does not change preferences or files. +""" + +import re +import sys +from pathlib import Path + +CLOSE_KEY = 'closeLeagueClientDuringGame' +OPTIMIZATION_KEY = 'potatoModeEnabled' +TRUE_VALUES = {'true', 'yes', '1'} +FALSE_VALUES = {'false', 'no', '0'} + + +def read_bool(path, key): + """Return True/False for the last value, or None when absent/unreadable.""" + try: + lines = Path(path).read_text().splitlines() + except (OSError, UnicodeError): + return None + value = None + for line in lines: + match = re.match(r'^\s*' + re.escape(key) + r':\s*(\S+)', line) + if match: + value = match.group(1).strip('"\'').lower() + if value in TRUE_VALUES: + return True + if value in FALSE_VALUES: + return False + return None + + +def mode(path): + if read_bool(path, OPTIMIZATION_KEY) is not True: + return 'none' + return 'close' if read_bool(path, CLOSE_KEY) is True else 'hide' + + +def main(argv=None): + argv = sys.argv[1:] if argv is None else argv + path = argv[0] if argv else '' + print(mode(path)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 91264d8..68ad0b9 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 """Opt-in: hide the League client UI while a match is running. -`RCLOL_HIDE_CLIENT_IN_GAME=1` is an alternative to closing the client UI with -`RCLOL_CLOSE_CLIENT_IN_GAME=1`. It hides the managed X11 windows for -`LeagueClientUx` and `Riot Client`, and shows them again when the game process -exits. The Riot client, League backend, CEF helpers and game keep running. +The launcher's `RCLOL_CLIENT_IN_GAME_TRICKS=1` mode selects this helper when +the client's own close-during-game preference is false. The low-level +`RCLOL_HIDE_CLIENT_IN_GAME=1` switch remains available for direct launches. It +hides the managed X11 windows for `LeagueClientUx` and `Riot Client`, and shows +them again when the game process exits. The Riot client, League backend, CEF +helpers and game keep running. This is launcher/window management only. It does not patch client binaries, CEF, anti-cheat or DRM, and it never hides the game window. It is off by @@ -332,13 +334,14 @@ def main(argv=None): help='one game observation and exit (no window changes)') args = parser.parse_args(argv) - if not args.once and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': + tricks = os.environ.get('RCLOL_CLIENT_IN_GAME_TRICKS') == '1' + if not args.once and not tricks and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence', file=sys.stderr, flush=True) return 0 - if not args.once and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': - print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1', - flush=True) + if not args.once and not tricks and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': + print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1 or ' + 'RCLOL_CLIENT_IN_GAME_TRICKS=1', flush=True) return 0 prefix = target_prefix() if prefix is None: diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 88dc083..7d76a6d 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -12,23 +12,30 @@ 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 +# Opt-in automatic client-in-game handling. This single switch reads the +# client's own lol-user-experience preferences: close the UI when the user +# selected "close client during game", otherwise hide the client windows. +# It does nothing unless potatoModeEnabled (client optimizations) is true. +if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then + pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml" + mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none) + case "$mode" in + close) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2 + python3 "$runner_dir/close-league-client-in-game.py" --detach & + ;; + hide) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2 + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + ;; + *) + echo 'client-in-game-tricks: no action (client optimizations disabled or preferences unreadable)' >&2 + ;; + esac +elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then python3 "$runner_dir/close-league-client-in-game.py" --detach & -fi -# Opt-in alternative for users who keep the client open during a match: hide -# the managed client windows instead of closing the UI. It never touches the -# League backend, the Riot client or the game window; it restores the window -# when the game process ends. Close and hide are mutually exclusive. -if [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then - if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then - echo 'RCLOL_HIDE_CLIENT_IN_GAME ignored: RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence' >&2 - else - python3 "$runner_dir/hide-league-client-in-game.py" --detach & - fi +elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/hide-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. From 9bcd41e12f4c4e073ab638a501c61bbbf192e0b3 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:07:47 -0600 Subject: [PATCH 06/13] fix(launcher): fail closed on automatic mode edge cases - restrict preference reads to the lol-user-experience section; - distinguish a failed close-preference read from a missing key; - reject xprop's 'not found' text as a WM_CLASS value; - track a window before unmap so an interrupting signal still restores it; - report the tricks-mode opt-out in the hide helper's startup message; - cover the new cases with preference, window and interruption tests. --- tests/test_client_in_game_tricks.py | 26 +++++++++++-- tests/test_hide_league_client_in_game.py | 43 +++++++++++++++++++++ tools/client-in-game-tricks.py | 49 +++++++++++++++++------- tools/hide-league-client-in-game.py | 10 +++-- 4 files changed, 107 insertions(+), 21 deletions(-) diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py index 6477571..4b19d28 100644 --- a/tests/test_client_in_game_tricks.py +++ b/tests/test_client_in_game_tricks.py @@ -51,12 +51,30 @@ class ClientInGameTricksTests(unittest.TestCase): def test_last_duplicate_value_wins_and_quotes_are_accepted(self): self.path.write_text( - 'potatoModeEnabled: false\n' - 'closeLeagueClientDuringGame: false\n' - 'potatoModeEnabled: "true"\n' - 'closeLeagueClientDuringGame: "true"\n') + 'lol-user-experience:\n' + ' data:\n' + ' potatoModeEnabled: false\n' + ' closeLeagueClientDuringGame: false\n' + ' potatoModeEnabled: "true"\n' + ' closeLeagueClientDuringGame: "true"\n') self.assertEqual(tricks.mode(self.path), 'close') + def test_keys_outside_the_named_section_are_ignored(self): + self.path.write_text( + 'lol-user-experience:\n' + ' data:\n' + ' potatoModeEnabled: true\n' + 'other-section:\n' + ' potatoModeEnabled: true\n' + ' closeLeagueClientDuringGame: true\n') + self.assertEqual(tricks.mode(self.path), 'hide') + + def test_second_read_failure_fails_closed(self): + self.write(True, False) + text = self.path.read_text() + with patch.object(Path, 'read_text', side_effect=[text, OSError()]): + self.assertEqual(tricks.mode(self.path), 'none') + def test_main_prints_mode(self): self.write(True, False) output = io.StringIO() diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 95be689..946bbad 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -156,6 +156,49 @@ class HideClientInGameTests(unittest.TestCase): self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10]) self.assertEqual(classes.call_count, 1) + def test_missing_wm_class_is_not_cached_as_a_class(self): + with patch.object(hide_in_game, 'xprop_value', + return_value='WM_CLASS: not found.'): + self.assertEqual(hide_in_game.window_class('0x10'), '') + + def test_unmap_failure_removes_the_window_from_hidden(self): + class FailingX11(FakeX11): + def unmap(self, window): + raise RuntimeError('unmap failed') + + watcher = hide_in_game.Watcher( + self.prefix, FailingX11(), + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=io.StringIO(), + ) + watcher._hide_new() + self.assertEqual(watcher.hidden, set()) + + def test_interrupted_unmap_keeps_the_window_tracked(self): + shown = [] + + class InterruptingX11: + def unmap(self, window): + raise KeyboardInterrupt + + def show(self, window): + shown.append(window) + + watcher = hide_in_game.Watcher( + self.prefix, InterruptingX11(), + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=io.StringIO(), + ) + with self.assertRaises(KeyboardInterrupt): + watcher._hide_new() + self.assertEqual(watcher.hidden, {0xabc}) + watcher.restore() + self.assertEqual(shown, [0xabc]) + def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) x11 = FakeX11() diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py index bdc8d6b..adf28c6 100755 --- a/tools/client-in-game-tricks.py +++ b/tools/client-in-game-tricks.py @@ -7,32 +7,45 @@ The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads - potatoModeEnabled must be true (client optimizations enabled); - closeLeagueClientDuringGame true -> close the client UI while a match runs; - closeLeagueClientDuringGame false -> hide the client windows instead; -- missing/unreadable preferences -> no action (none). +- missing preferences or an unreadable file -> no action (none). -This helper only selects a mode. It does not change preferences or files. +Only the named `lol-user-experience` section is considered, so identical keys +in another section cannot select a helper. This helper only selects a mode; it +does not change preferences or files. """ -import re import sys from pathlib import Path +SECTION = 'lol-user-experience' CLOSE_KEY = 'closeLeagueClientDuringGame' OPTIMIZATION_KEY = 'potatoModeEnabled' TRUE_VALUES = {'true', 'yes', '1'} FALSE_VALUES = {'false', 'no', '0'} -def read_bool(path, key): - """Return True/False for the last value, or None when absent/unreadable.""" - try: - lines = Path(path).read_text().splitlines() - except (OSError, UnicodeError): - return None +def read_bool(path, key, section=SECTION): + """Return True/False for the last key in section, or None when absent. + + OSError/UnicodeError is raised for an unreadable file so callers can fail + closed instead of treating a read race as a missing preference. + """ + lines = Path(path).read_text().splitlines() + in_section = False value = None for line in lines: - match = re.match(r'^\s*' + re.escape(key) + r':\s*(\S+)', line) - if match: - value = match.group(1).strip('"\'').lower() + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + if not line.startswith((' ', '\t')): + if stripped == section + ':': + in_section = True + elif in_section: + break + continue + if not in_section or not stripped.startswith(key + ':'): + continue + value = stripped.split(':', 1)[1].strip().strip('"\'').lower() if value in TRUE_VALUES: return True if value in FALSE_VALUES: @@ -41,9 +54,17 @@ def read_bool(path, key): def mode(path): - if read_bool(path, OPTIMIZATION_KEY) is not True: + try: + optimization = read_bool(path, OPTIMIZATION_KEY) + except (OSError, UnicodeError): return 'none' - return 'close' if read_bool(path, CLOSE_KEY) is True else 'hide' + if optimization is not True: + return 'none' + try: + close = read_bool(path, CLOSE_KEY) + except (OSError, UnicodeError): + return 'none' + return 'close' if close is True else 'hide' def main(argv=None): diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 68ad0b9..bf6f212 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -129,7 +129,8 @@ def xprop_value(window, prop): def window_class(window): - return xprop_value(window, 'WM_CLASS').lower() + value = xprop_value(window, 'WM_CLASS').lower() + return value if '=' in value else '' def window_pid(window): @@ -295,11 +296,12 @@ class Watcher: for window in windows: if window in self.hidden: continue + self.hidden.add(window) try: self.x11.unmap(window) - self.hidden.add(window) print(f'hide-league-client-in-game: hidden 0x{window:x}', file=self.out, flush=True) except Exception as error: + self.hidden.discard(window) print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', file=sys.stderr, flush=True) @@ -372,8 +374,10 @@ def main(argv=None): for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): signal.signal(signum, stop) + optout = ('RCLOL_CLIENT_IN_GAME_TRICKS=0' if tricks + else 'RCLOL_HIDE_CLIENT_IN_GAME=0') print('hide-league-client-in-game: watching %s for a running match; ' - 'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True) + 'opt out with %s' % (prefix, optout), flush=True) try: watcher.run() except KeyboardInterrupt: From 4ef09cb92a2ef9ba7d9ad2ae0514d5faaeaf6a39 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:27:33 -0600 Subject: [PATCH 07/13] 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. --- .github/workflows/source-check.yml | 1 + README.md | 8 ++ docs/SETUP.md | 8 ++ docs/TROUBLESHOOTING.md | 9 ++ tests/test_recover_stale_client.py | 110 +++++++++++++++ tools/recover-stale-client.py | 212 +++++++++++++++++++++++++++++ tools/run-riot-client.sh | 6 + 7 files changed, 354 insertions(+) create mode 100644 tests/test_recover_stale_client.py create mode 100755 tools/recover-stale-client.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index a866b9d..df99278 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -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' diff --git a/README.md b/README.md index b6144f7..111ae26 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/SETUP.md b/docs/SETUP.md index 0c0d39b..e3cafa8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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_*`), diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 13a2f7b..64d4289 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -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) diff --git a/tests/test_recover_stale_client.py b/tests/test_recover_stale_client.py new file mode 100644 index 0000000..743907f --- /dev/null +++ b/tests/test_recover_stale_client.py @@ -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() diff --git a/tools/recover-stale-client.py b/tools/recover-stale-client.py new file mode 100755 index 0000000..3c29805 --- /dev/null +++ b/tools/recover-stale-client.py @@ -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()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 767294b..085434e 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -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 From 9172bb6c3d54fe5847e457bfcffc5b135f56a53b Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:30:45 -0600 Subject: [PATCH 08/13] fix(launcher): handle YAML comments and log failures - strip inline YAML comments from preference values, including quoted values; - keep a successfully unmapped window tracked when the success log raises BrokenPipeError, while still discarding a window whose unmap failed; - cover both cases with tests. --- tests/test_client_in_game_tricks.py | 8 ++++++++ tests/test_hide_league_client_in_game.py | 18 ++++++++++++++++++ tools/client-in-game-tricks.py | 9 ++++++++- tools/hide-league-client-in-game.py | 8 +++++++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py index 4b19d28..53a0db3 100644 --- a/tests/test_client_in_game_tricks.py +++ b/tests/test_client_in_game_tricks.py @@ -69,6 +69,14 @@ class ClientInGameTricksTests(unittest.TestCase): ' closeLeagueClientDuringGame: true\n') self.assertEqual(tricks.mode(self.path), 'hide') + def test_inline_yaml_comments_are_ignored(self): + self.path.write_text( + 'lol-user-experience:\n' + ' data:\n' + ' potatoModeEnabled: true # client optimizations\n' + ' closeLeagueClientDuringGame: "true" # close during matches\n') + self.assertEqual(tricks.mode(self.path), 'close') + def test_second_read_failure_fails_closed(self): self.write(True, False) text = self.path.read_text() diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 946bbad..f03e8c1 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -199,6 +199,24 @@ class HideClientInGameTests(unittest.TestCase): watcher.restore() self.assertEqual(shown, [0xabc]) + def test_log_failure_does_not_discard_an_unmapped_window(self): + class BrokenOutput: + def write(self, _text): + raise BrokenPipeError + + def flush(self): + pass + + watcher = hide_in_game.Watcher( + self.prefix, FakeX11(), + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=BrokenOutput(), + ) + watcher._hide_new() + self.assertEqual(watcher.hidden, {0xabc}) + def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) x11 = FakeX11() diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py index adf28c6..1b7a314 100755 --- a/tools/client-in-game-tricks.py +++ b/tools/client-in-game-tricks.py @@ -45,7 +45,14 @@ def read_bool(path, key, section=SECTION): continue if not in_section or not stripped.startswith(key + ':'): continue - value = stripped.split(':', 1)[1].strip().strip('"\'').lower() + value = stripped.split(':', 1)[1].strip() + if value[:1] in ('"', "'"): + quote = value[0] + end = value.find(quote, 1) + value = value[1:end] if end != -1 else value[1:] + else: + value = value.split('#', 1)[0].strip() + value = value.lower() if value in TRUE_VALUES: return True if value in FALSE_VALUES: diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index bf6f212..22b9ca2 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -299,11 +299,17 @@ class Watcher: self.hidden.add(window) try: self.x11.unmap(window) - print(f'hide-league-client-in-game: hidden 0x{window:x}', file=self.out, flush=True) except Exception as error: self.hidden.discard(window) print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', file=sys.stderr, flush=True) + continue + try: + print(f'hide-league-client-in-game: hidden 0x{window:x}', + file=self.out, flush=True) + except Exception: + # Logging must not lose a successfully unmapped window. + pass def run(self, max_steps=None): steps = 0 From c12ab8455952a78719b55c42c76170602c3e3162 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:50:38 -0600 Subject: [PATCH 09/13] fix(launcher): remove orphaned client-in-game helpers A launcher can exit while its long-lived close/hide helper remains. The next run then starts another watcher, so several copies watch the same prefix. Add an opt-in RCLOL_CLEAN_STALE_HELPERS=1 preflight that removes only helper processes of this prefix reparented to init/systemd. Helpers of a live launcher, other prefixes, the game, Riot/League/CEF, darlingserver and the runtime are preserved. Unreadable or relative process data leaves a process alone. Validated across repeated launcher starts: the cleanup removed four, then one, then one orphaned watcher; a subsequent dry run reported no orphaned helpers. Zombie reaping after partial recovery is tracked separately in #124. --- .github/workflows/source-check.yml | 1 + README.md | 12 ++ docs/SETUP.md | 9 ++ docs/TROUBLESHOOTING.md | 24 ++++ tests/test_clean_stale_helpers.py | 110 +++++++++++++++++ tools/clean-stale-helpers.py | 183 +++++++++++++++++++++++++++++ tools/run-riot-client.sh | 7 ++ 7 files changed, 346 insertions(+) create mode 100644 tests/test_clean_stale_helpers.py create mode 100755 tools/clean-stale-helpers.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index df99278..9594dc3 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -32,3 +32,4 @@ jobs: - 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' diff --git a/README.md b/README.md index 111ae26..2e7ebf0 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,18 @@ 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). +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). + ## 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. diff --git a/docs/SETUP.md b/docs/SETUP.md index e3cafa8..c8178a6 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -86,6 +86,15 @@ 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_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_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_*`), diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 64d4289..ee79ad9 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -158,6 +158,30 @@ 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. +### 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) diff --git a/tests/test_clean_stale_helpers.py b/tests/test_clean_stale_helpers.py new file mode 100644 index 0000000..52c0294 --- /dev/null +++ b/tests/test_clean_stale_helpers.py @@ -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() diff --git a/tools/clean-stale-helpers.py b/tools/clean-stale-helpers.py new file mode 100755 index 0000000..a01d49a --- /dev/null +++ b/tools/clean-stale-helpers.py @@ -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()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 085434e..34e7e06 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -18,6 +18,13 @@ 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 # 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 From 604073448cf43ea64b634a9317c56fa2f50a75ed Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 06:08:45 -0600 Subject: [PATCH 10/13] fix(launcher): address in-game helper review - restore hidden windows even when the launcher output pipe is closed; - exit the hide watcher after ten idle minutes and start it only after the installed-client check; - reject missing, malformed or invalid close-during-game preferences instead of silently selecting hide; - document the preference contract and idle behavior. --- README.md | 12 ++--- docs/SETUP.md | 11 ++--- docs/TROUBLESHOOTING.md | 5 ++- tests/test_client_in_game_tricks.py | 8 +++- tests/test_hide_league_client_in_game.py | 45 +++++++++++++++++++ tools/client-in-game-tricks.py | 57 +++++++++++++++++------- tools/hide-league-client-in-game.py | 57 +++++++++++++++++------- tools/run-riot-client.sh | 52 ++++++++++----------- 8 files changed, 178 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 51a3cf4..689f53f 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,13 @@ RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh It reads `lol-user-experience` from the installed League client's `LCULocalPreferences.yaml`. When `potatoModeEnabled` is true, it closes the -client UI if `closeLeagueClientDuringGame` is true; otherwise it hides the -`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game -ends. With client optimizations disabled or unreadable preferences it does -nothing. The `RCLOL_CLOSE_CLIENT_IN_GAME` and `RCLOL_HIDE_CLIENT_IN_GAME` -low-level switches remain available when the automatic switch is not set. See +client UI if `closeLeagueClientDuringGame` is true, and hides the +`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or +invalid preference values, disabled client optimizations and unreadable files +select no action. The `RCLOL_CLOSE_CLIENT_IN_GAME` and +`RCLOL_HIDE_CLIENT_IN_GAME` low-level switches remain available when the +automatic switch is not set. The helper exits after ten minutes without a +client or match once it has seen one. See [troubleshooting](docs/TROUBLESHOOTING.md#automatic-client-handling-during-a-match) for the measured effect and caveats. diff --git a/docs/SETUP.md b/docs/SETUP.md index 2679892..548a02f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -81,13 +81,14 @@ for the observed failures, recovery guards and NSURLSession limitations. `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 -UI when `closeLeagueClientDuringGame: true`, otherwise it hides the managed -`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game -ends. With client optimizations disabled or preferences unreadable it does -nothing. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and +UI when `closeLeagueClientDuringGame: true`, and hides the managed +`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or +invalid preference values, disabled client optimizations and unreadable files +select no action. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and `RCLOL_HIDE_CLIENT_IN_GAME=1` switches remain available when the automatic switch is not set. The feature is off by default, requires X11/Xwayland, and -does not patch client binaries, CEF or the game. See +does not patch client binaries, CEF or the game. The hide helper exits after ten +minutes without a client or match once it has seen one. See [the troubleshooting measurement](TROUBLESHOOTING.md#automatic-client-handling-during-a-match) for the measured CPU effect and limitations. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 0c37ba5..7869d27 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -206,13 +206,16 @@ It reads `lol-user-experience` from the installed client's | Client preference | Launcher action | |---|---| | `potatoModeEnabled: false` or unreadable | no client-in-game helper | +| `potatoModeEnabled: true`, close preference missing or invalid | no action | | `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI | | `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows | The hide path unmaps the `LeagueClientUx` and `Riot Client` X11 windows and maps them again when the game process ends. The client is not closed, the League backend and Riot client keep running, and the game window is never hidden. The -close path uses the UI-close helper described above. +close path uses the UI-close helper described above. The hide helper exits after +ten minutes without a client or match once it has seen one; a missing or invalid +close preference selects no helper. The low-level `RCLOL_HIDE_CLIENT_IN_GAME=1` and `RCLOL_CLOSE_CLIENT_IN_GAME=1` switches remain available when the automatic diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py index 53a0db3..752435b 100644 --- a/tests/test_client_in_game_tricks.py +++ b/tests/test_client_in_game_tricks.py @@ -36,9 +36,12 @@ class ClientInGameTricksTests(unittest.TestCase): self.write(True, False) self.assertEqual(tricks.mode(self.path), 'hide') - def test_hide_when_close_preference_is_missing(self): + def test_none_when_close_preference_is_missing_or_invalid(self): self.write(True) - self.assertEqual(tricks.mode(self.path), 'hide') + self.assertEqual(tricks.mode(self.path), 'none') + for value in ('maybe', '"true', 'true#note', '"true"junk'): + self.write(True, value) + self.assertEqual(tricks.mode(self.path), 'none', value) def test_none_when_optimizations_are_off(self): self.write(False, True) @@ -64,6 +67,7 @@ class ClientInGameTricksTests(unittest.TestCase): 'lol-user-experience:\n' ' data:\n' ' potatoModeEnabled: true\n' + ' closeLeagueClientDuringGame: false\n' 'other-section:\n' ' potatoModeEnabled: true\n' ' closeLeagueClientDuringGame: true\n') diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index f03e8c1..867f2f3 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -217,6 +217,46 @@ class HideClientInGameTests(unittest.TestCase): watcher._hide_new() self.assertEqual(watcher.hidden, {0xabc}) + def test_restore_log_failure_still_shows_the_window(self): + class BrokenOutput: + def write(self, _text): + raise BrokenPipeError + + def flush(self): + pass + + x11 = FakeX11() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=BrokenOutput(), + ) + watcher.hidden = {0xabc} + watcher._show_all('test') + self.assertEqual(x11.shown, [0xabc]) + self.assertEqual(watcher.hidden, set()) + + def test_idle_timeout_leaves_after_client_disappears(self): + states = iter([True, False, False]) + windows = iter([[0xabc], [], []]) + times = iter([0.0, 10.0, 700.0]) + x11 = FakeX11() + output = io.StringIO() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda _prefix, _cache: next(windows), + state_source=lambda _prefix: next(states), + sleep=lambda _seconds: None, + out=output, + clock=lambda: next(times), + ) + self.assertEqual(watcher.run(max_steps=3), 0) + self.assertEqual(x11.unmapped, [0xabc]) + self.assertEqual(x11.shown, [0xabc]) + self.assertIn('no client or match', output.getvalue()) + def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) x11 = FakeX11() @@ -278,6 +318,11 @@ class HideClientInGameTests(unittest.TestCase): }): self.assertEqual(hide_in_game.main([]), 0) + def test_helper_starts_after_the_installed_client_check(self): + source = (ROOT / 'tools/run-riot-client.sh').read_text() + self.assertLess(source.index('Riot Client is not installed in'), + source.index('RCLOL_CLIENT_IN_GAME_TRICKS')) + def test_master_tricks_switch_enables_hide(self): class FakeX11: def close(self): diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py index 1b7a314..2d4330c 100755 --- a/tools/client-in-game-tricks.py +++ b/tools/client-in-game-tricks.py @@ -7,7 +7,8 @@ The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads - potatoModeEnabled must be true (client optimizations enabled); - closeLeagueClientDuringGame true -> close the client UI while a match runs; - closeLeagueClientDuringGame false -> hide the client windows instead; -- missing preferences or an unreadable file -> no action (none). +- missing or invalid closeLeagueClientDuringGame or potatoModeEnabled -> no action + (none). Only the named `lol-user-experience` section is considered, so identical keys in another section cannot select a helper. This helper only selects a mode; it @@ -24,8 +25,39 @@ TRUE_VALUES = {'true', 'yes', '1'} FALSE_VALUES = {'false', 'no', '0'} +def parse_bool(value): + """Return True/False/None for one YAML scalar, ignoring a trailing comment.""" + value = value.strip() + if not value: + return None + if value[0] in ('"', "'"): + quote = value[0] + end = value.find(quote, 1) + if end == -1: + return None + scalar = value[1:end] + remainder = value[end + 1:] + if remainder and not remainder[0].isspace(): + return None + remainder = remainder.strip() + if remainder and not remainder.startswith('#'): + return None + else: + scalar = value + for index, char in enumerate(scalar): + if char == '#' and (index == 0 or scalar[index - 1].isspace()): + scalar = scalar[:index].rstrip() + break + scalar = scalar.lower() + if scalar in TRUE_VALUES: + return True + if scalar in FALSE_VALUES: + return False + return None + + def read_bool(path, key, section=SECTION): - """Return True/False for the last key in section, or None when absent. + """Return True/False for the last key in section, or None when invalid. OSError/UnicodeError is raised for an unreadable file so callers can fail closed instead of treating a read race as a missing preference. @@ -45,19 +77,8 @@ def read_bool(path, key, section=SECTION): continue if not in_section or not stripped.startswith(key + ':'): continue - value = stripped.split(':', 1)[1].strip() - if value[:1] in ('"', "'"): - quote = value[0] - end = value.find(quote, 1) - value = value[1:end] if end != -1 else value[1:] - else: - value = value.split('#', 1)[0].strip() - value = value.lower() - if value in TRUE_VALUES: - return True - if value in FALSE_VALUES: - return False - return None + value = parse_bool(stripped.split(':', 1)[1]) + return value def mode(path): @@ -71,7 +92,11 @@ def mode(path): close = read_bool(path, CLOSE_KEY) except (OSError, UnicodeError): return 'none' - return 'close' if close is True else 'hide' + if close is True: + return 'close' + if close is False: + return 'hide' + return 'none' def main(argv=None): diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 22b9ca2..f9135af 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -39,6 +39,7 @@ GAME_NAME = 'LeagueofLegends' SOCK_VARIABLE = b'__mldr_sockpath' SOCK_NAME = '.darlingserver.sock' POLL_SECONDS = 2.0 +IDLE_SECONDS = 600 TARGET_CLASSES = ('leagueclientux', 'riot client') @@ -263,35 +264,46 @@ class Watcher: """Hide while a match is running, restore on end, uncertainty or exit.""" def __init__(self, prefix, x11, window_source=target_windows, - state_source=game_running, sleep=time.sleep, out=sys.stdout): + state_source=game_running, sleep=time.sleep, out=sys.stdout, + clock=time.monotonic): self.prefix = prefix self.x11 = x11 self.window_source = window_source self.state_source = state_source self.sleep = sleep self.out = out + self.clock = clock self.hidden = set() self.class_cache = {} self.reported_no_window = False + self.seen = False + self.idle_since = None + + def _log(self, message, stream=None): + try: + print(message, file=self.out if stream is None else stream, flush=True) + except Exception: + # A closed launcher pipe must not prevent window restoration. + pass def _show_all(self, reason): if not self.hidden: return - print(f'hide-league-client-in-game: {reason}; showing the client', file=self.out, flush=True) + self._log(f'hide-league-client-in-game: {reason}; showing the client') for window in sorted(self.hidden): try: self.x11.show(window) - print(f'hide-league-client-in-game: shown 0x{window:x}', file=self.out, flush=True) except Exception as error: - print(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}', - file=sys.stderr, flush=True) + self._log(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}', + sys.stderr) + continue + self._log(f'hide-league-client-in-game: shown 0x{window:x}') self.hidden.clear() def _hide_new(self): windows = self.window_source(self.prefix, self.class_cache) if not windows and not self.hidden and not self.reported_no_window: - print('hide-league-client-in-game: no managed client window; not hiding', - file=self.out, flush=True) + self._log('hide-league-client-in-game: no managed client window; not hiding') self.reported_no_window = True for window in windows: if window in self.hidden: @@ -301,29 +313,44 @@ class Watcher: self.x11.unmap(window) except Exception as error: self.hidden.discard(window) - print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', - file=sys.stderr, flush=True) + self._log(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', + sys.stderr) continue - try: - print(f'hide-league-client-in-game: hidden 0x{window:x}', - file=self.out, flush=True) - except Exception: - # Logging must not lose a successfully unmapped window. - pass + self._log(f'hide-league-client-in-game: hidden 0x{window:x}') + + def _windows_present(self): + try: + return bool(self.window_source(self.prefix, self.class_cache)) + except Exception: + # Uncertainty must not turn into an automatic exit. + return True def run(self, max_steps=None): steps = 0 while True: + now = self.clock() state = self.state_source(self.prefix) if state is None: self._show_all('unreadable /proc state') elif state: + self.seen = True + self.idle_since = None if not self.hidden and self.reported_no_window: self.reported_no_window = False self._hide_new() else: self._show_all('the match ended') self.reported_no_window = False + if self._windows_present(): + self.seen = True + self.idle_since = None + elif self.seen: + if self.idle_since is None: + self.idle_since = now + if now - self.idle_since >= IDLE_SECONDS: + self._log('hide-league-client-in-game: no client or match for ' + f'{IDLE_SECONDS}s: leaving') + return 0 steps += 1 if max_steps is not None and steps >= max_steps: return 0 diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 7d76a6d..611c810 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -12,31 +12,6 @@ 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 automatic client-in-game handling. This single switch reads the -# client's own lol-user-experience preferences: close the UI when the user -# selected "close client during game", otherwise hide the client windows. -# It does nothing unless potatoModeEnabled (client optimizations) is true. -if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then - pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml" - mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none) - case "$mode" in - close) - echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2 - python3 "$runner_dir/close-league-client-in-game.py" --detach & - ;; - hide) - echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2 - python3 "$runner_dir/hide-league-client-in-game.py" --detach & - ;; - *) - echo 'client-in-game-tricks: no action (client optimizations disabled or preferences unreadable)' >&2 - ;; - esac -elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then - python3 "$runner_dir/close-league-client-in-game.py" --detach & -elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then - python3 "$runner_dir/hide-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}" @@ -84,6 +59,33 @@ if [ -z "${RIOT_LAUNCHER_HOST:-}" ] && [ ! -e "$client" ]; then ln -s "../Users/Shared/Riot Games/Riot Client.app" "$client" fi fi +# Opt-in automatic client-in-game handling. Start the watcher only after the +# installed-client check above: a failed installer launch must not leave a +# helper behind. The preference selector returns no action for missing or +# invalid close-during-game values. +if [ -z "${RIOT_LAUNCHER_HOST:-}" ]; then + if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then + pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml" + mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none) + case "$mode" in + close) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2 + python3 "$runner_dir/close-league-client-in-game.py" --detach & + ;; + hide) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2 + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + ;; + *) + echo 'client-in-game-tricks: no action (client optimizations disabled or preferences missing/invalid)' >&2 + ;; + esac + elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/close-league-client-in-game.py" --detach & + elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + fi +fi A=${RIOT_LAUNCHER_HOST:-"$client/Contents/MacOS/RiotClientServices"} # External XIM (for example Fcitx) consumes keys before Darling forwards them. # Use Xlib's local input method; allow explicit testing of another modifier. From 308fb3f3cec5bed73e142527a3646cb40e6ee867 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 06:08:45 -0600 Subject: [PATCH 11/13] fix(launcher): harden stale-client recovery - fail closed when X11 omits _NET_CLIENT_LIST or WM_CLASS, and recognize the RiotClientServices window class; - require stale client processes to be at least 60 seconds old; - recheck process identity before SIGTERM and SIGKILL and recheck the window before signalling; - document the age and window-detection guards. --- README.md | 7 +- docs/SETUP.md | 9 +-- docs/TROUBLESHOOTING.md | 16 +++-- tests/test_recover_stale_client.py | 57 ++++++++++++++- tools/recover-stale-client.py | 110 ++++++++++++++++++----------- 5 files changed, 144 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 2e7ebf0..f9154eb 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,12 @@ If the game or Riot client was closed but `darlingserver` is still running, opt 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). +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: diff --git a/docs/SETUP.md b/docs/SETUP.md index c8178a6..cdcdbc8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -80,10 +80,11 @@ 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 +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. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ee79ad9..571dce4 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -150,13 +150,15 @@ 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. +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 diff --git a/tests/test_recover_stale_client.py b/tests/test_recover_stale_client.py index 743907f..14099a0 100644 --- a/tests/test_recover_stale_client.py +++ b/tests/test_recover_stale_client.py @@ -24,16 +24,19 @@ class RecoverStaleClientTests(unittest.TestCase): 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): + 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:])) - (folder / 'stat').write_text(f'{pid} ({comm}) S') + 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 @@ -45,11 +48,13 @@ class RecoverStaleClientTests(unittest.TestCase): os.fsencode(prefix / recover_stale_client.SOCK_NAME), ) - def run_recover(self, window=False): + 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', @@ -105,6 +110,52 @@ class RecoverStaleClientTests(unittest.TestCase): 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() diff --git a/tools/recover-stale-client.py b/tools/recover-stale-client.py index 3c29805..66466ce 100755 --- a/tools/recover-stale-client.py +++ b/tools/recover-stale-client.py @@ -7,15 +7,17 @@ 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. +- 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 @@ -23,7 +25,8 @@ import time from pathlib import Path GAME_NAME = 'LeagueofLegends' -WINDOW_CLASSES = ('leagueclientux', 'riot client') +WINDOW_CLASSES = ('leagueclientux', 'riot client', 'riotclientservices') +MIN_AGE_SECONDS = 60 CLIENT_MARKERS = ( '/Applications/League of Legends.app', '/Applications/Riot Client.app', @@ -102,8 +105,9 @@ def game_running(proc_root=Path('/proc')): 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. + 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( @@ -111,56 +115,71 @@ def visible_client_window(): ) except (OSError, subprocess.SubprocessError): return True - for raw in listing.split(): - if not raw.startswith('0x'): - continue + 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.""" - 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 + if proc.name.isdigit() and client_identity(prefix, proc.name, proc_root) is not None: found.append(int(proc.name)) - except (OSError, ValueError): - continue return sorted(found) @@ -175,19 +194,30 @@ def recover(prefix, proc_root=Path('/proc'), window_check=visible_client_window, 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: + 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(pids), file=out, flush=True) - for pid in pids: + '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 in pids: + for pid, start_ticks in identities.items(): + if client_identity(prefix, pid, proc_root) != start_ticks: + continue try: kill(pid, signal.SIGKILL) except OSError: From a53dd00ff940e90b9a7f794c7b82cde4e03543e8 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 11:24:24 -0600 Subject: [PATCH 12/13] docs: document Vulkan ICD selection for multiple GPUs --- docs/TROUBLESHOOTING.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f4a5488..dd6a956 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -500,6 +500,41 @@ With the window visual and compositor draw buffer fixes (#22) and the RPC socket If the game window stays black on NVIDIA, check `metal-device.log` as described in [Check which GPU rendered](#check-which-gpu-rendered). +## Vulkan device or ICD selection + +The Metal wrapper renders with Vulkan and hands each frame to an OpenGL (EGL) +context. If the machine has more than one Vulkan ICD, or a software Vulkan +device such as lavapipe, the loader can enumerate a device that does not match +the presenting GPU. The client may then stop with a Metal device or version +error even though the runtime built correctly ([#127](https://github.com/lucannez64/darling-lol/issues/127)). + +Check what the loader reports and what Metal selected: + +```sh +ls -l /usr/share/vulkan/icd.d/ +vulkaninfo --summary +grep -E 'physical name|selected name|presenting GL' ~/.darling/private/tmp/metal-device.log +``` + +If the selected device is wrong, force the ICD for the GPU that should present: + +```sh +VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/radeon_icd.x86_64.json sh scripts/run.sh +``` + +The filename is distribution and driver specific (`radeon_icd.x86_64.json`, +`nvidia_icd.json` and `intel_icd.x86_64.json` are common). Choose the JSON for +the GPU you want, and set this per launch; do not install it globally in a shell +profile. `VK_ICD_FILENAMES` changes the Vulkan side only. If the log reports +`MISMATCH`, the presenting OpenGL context is on another GPU; use the PRIME or +EGL variables in [hybrid graphics](#hybrid-graphics-laptops-amdintel-igpu--nvidia) +as well. If `physical name` shows `llvmpipe` or another CPU device, select the +hardware ICD or enable [`/tmp/metal-skip-cpu-devices`](SETUP.md#metal-and-indium-overlays). + +This workaround selects a device; it does not make an unsupported Metal feature +available. Include the `physical`, `selected` and `presenting` lines from +`metal-device.log` when reporting the failure. + ## Hybrid graphics laptops (AMD/Intel iGPU + NVIDIA) Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured 60-70 FPS in ARAM on the integrated GPU; on the NVIDIA GPU the game showed a black screen, since fixed (see [NVIDIA](#nvidia)). From 30a178b348caa572a7e51e40384ec0b4387fa1b0 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:29:21 +0100 Subject: [PATCH 13/13] docs: scope GPU diagnostics to one logged reproduction Signed-off-by: lucannez64 <58551020+lucannez64@users.noreply.github.com> --- docs/TROUBLESHOOTING.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 337820b..85200cc 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -513,13 +513,27 @@ Check what the loader reports and what Metal selected: ```sh ls -l /usr/share/vulkan/icd.d/ vulkaninfo --summary -grep -E 'physical name|selected name|presenting GL' ~/.darling/private/tmp/metal-device.log ``` -If the selected device is wrong, force the ICD for the GPU that should present: +The device log is opt-in and append-only. With the game and both clients closed, +clear the old log and enable logging for one reproduction: ```sh -VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/radeon_icd.x86_64.json sh scripts/run.sh +rm -f "${DPREFIX:-$HOME/.darling}/private/tmp/metal-device.log" +DARLING_METAL_DEVICE_LOG=1 sh scripts/run.sh +``` + +After reproducing, inspect the `physical`, `selected` and `presenting` lines +for the same process ID: + +```sh +grep -E 'physical name|selected name|presenting GL' "${DPREFIX:-$HOME/.darling}/private/tmp/metal-device.log" +``` + +If the selected device is wrong, restrict Vulkan to the intended driver's ICD: + +```sh +DARLING_METAL_DEVICE_LOG=1 VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/radeon_icd.x86_64.json sh scripts/run.sh ``` The filename is distribution and driver specific (`radeon_icd.x86_64.json`, @@ -531,8 +545,9 @@ EGL variables in [hybrid graphics](#hybrid-graphics-laptops-amdintel-igpu--nvidi as well. If `physical name` shows `llvmpipe` or another CPU device, select the hardware ICD or enable [`/tmp/metal-skip-cpu-devices`](SETUP.md#metal-and-indium-overlays). -This workaround selects a device; it does not make an unsupported Metal feature -available. Include the `physical`, `selected` and `presenting` lines from +This workaround selects an ICD/driver, which can expose several physical GPUs. +It does not guarantee a particular GPU or a match with the OpenGL presenting +GPU, and it does not make unsupported Metal features available. Include the `physical`, `selected` and `presenting` lines from `metal-device.log` when reporting the failure. ## Hybrid graphics laptops (AMD/Intel iGPU + NVIDIA)