From 11f72520043cd4255500aca6f104a2f3c9f07e29 Mon Sep 17 00:00:00 2001 From: MintMachineHQ Date: Fri, 23 Oct 2026 23:56:30 -0600 Subject: [PATCH 1/3] feat(tools): add host doctor, crash report and frame-pacing baseline Turns "it does not work" into an attachable report instead of a log-less issue. Three read-only host tools, reachable from the launcher: - doctor.py checks GPU/driver, Vulkan ICDs, display session, memory, disk, overlay layer, core-dump limit, i915 panel self-refresh and prefix/runtime state. --json and --report produce an issue attachment; warnings do not fail. - crash-report.py bundles the doctor checks, process table, dserver and launcher log tails, graphics kernel messages, display state and recent core dumps. Environment is never collected and home/user/host are redacted. - perf-baseline.py parses the game's own netstats.csv into average FPS, p50/p95/p99 frame time, slowest-quarter FPS and hitch count, with --json and --append for before/after comparisons. COMPATIBILITY.md records the verified UHD 620 row with measured numbers (24-32 avg FPS, p99 44-98 ms) and explains how to submit another one. --- README.md | 2 +- darling-lol-launcher.sh | 14 ++ docs/COMPATIBILITY.md | 83 +++++++ docs/TROUBLESHOOTING.md | 26 +++ tests/test_crash_report.py | 166 ++++++++++++++ tests/test_doctor.py | 215 ++++++++++++++++++ tests/test_perf_baseline.py | 150 +++++++++++++ tools/crash-report.py | 311 ++++++++++++++++++++++++++ tools/doctor.py | 431 ++++++++++++++++++++++++++++++++++++ tools/perf-baseline.py | 211 ++++++++++++++++++ 10 files changed, 1608 insertions(+), 1 deletion(-) create mode 100644 docs/COMPATIBILITY.md create mode 100644 tests/test_crash_report.py create mode 100644 tests/test_doctor.py create mode 100644 tests/test_perf_baseline.py create mode 100644 tools/crash-report.py create mode 100644 tools/doctor.py create mode 100644 tools/perf-baseline.py diff --git a/README.md b/README.md index 6dc7f33..a0212d6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ If someone else wants to contact me know that i'm open to jobs, part-time remote only as a employee and full time remote as a freelancer for a mission -A short manual ARAM run and the current compatibility status are documented in [docs/STATUS.md](docs/STATUS.md). +A short manual ARAM run and the current compatibility status are documented in [docs/STATUS.md](docs/STATUS.md). Real machines that have played, and their measured frame times, are listed in [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md); `darling-lol --doctor` and `darling-lol --perf` produce the data for a new row. Experimental source patches and tools for running the **macOS League of Legends client on x86-64 Linux through Darling**. diff --git a/darling-lol-launcher.sh b/darling-lol-launcher.sh index f8a124c..9992bd4 100644 --- a/darling-lol-launcher.sh +++ b/darling-lol-launcher.sh @@ -15,6 +15,20 @@ if [ -z "${DPREFIX:-}" ] && [ -f "$WORKSPACE/build/config.json" ]; then "$WORKSPACE/build/config.json" 2>/dev/null) || DPREFIX= fi export DPREFIX="${DPREFIX:-$HOME/.darling}" +# Read-only host tools. They run here rather than in the namespace so they can +# see the real process table, kernel messages and display, and so a crash report +# still works while a session holds the workspace lock. +host_tool() { + local tool="$SHARE_DIR/tools/$1.py" + shift + [ -f "$tool" ] || tool="$(dirname -- "$0")/tools/$(basename -- "${tool}")" + exec python3 "$tool" "$@" +} +case "${1:-}" in + --doctor) shift; host_tool doctor "$@";; + --report) shift; host_tool crash-report "$@";; + --perf) shift; host_tool perf-baseline "$@";; +esac if [ "${1:-}" = --set-prefix ]; then [ $# -eq 2 ] || { echo 'Usage: darling-lol --set-prefix PATH' >&2; exit 2; } # Resolve here: the namespace starts in the workspace, not in this directory. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md new file mode 100644 index 0000000..b2e1d52 --- /dev/null +++ b/docs/COMPATIBILITY.md @@ -0,0 +1,83 @@ +# Compatibility matrix + +This page answers one question: **on which machines has darling-lol actually +been used to play, and what did it run like?** + +It is a record of real reports, not a list of every configuration that might +work. "Verified" below means the Riot Client opened, League launched, a match +was played manually, and the store was reachable. Experimental or untested +hardware is not listed until someone sends a report. + +The project is unofficial and carries account risk. Never test on an account you +care about, and never play ranked while testing (see the warning in the +[README](../README.md)). + +## How to add a row + +Run these on the machine you played on, after a match: + +```sh +darling-lol --doctor # host, GPU, Vulkan, session, prefix checks +darling-lol --doctor --report doctor.json # same checks as a JSON attachment +darling-lol --perf # frame pacing from the game's own logs +darling-lol --perf --json # the same numbers as JSON +``` + +`lol doctor` needs no runtime and starts nothing, so it is safe to run at any +time. `lol perf` reads the `netstats.csv` files that League already writes; it +does not touch the client or the game. + +Open an issue titled `Compatibility: / ` and attach `doctor.json` +plus the `perf --json` output. Do not paste a full `~/.darling` log or your home +directory; `lol report` produces a redacted bundle if more diagnostics are +needed. Then add a row here in a pull request, or mention the issue and a +maintainer will add it. + +### What the numbers mean + +- `avgfps` is the mean of League's own ten-second `game.frametime_average` + samples over the whole match. +- `p50/p95/p99ms` are frame times: the typical frame, the slow 5 %, and the + slowest 1 %. +- `slowfps` is the average FPS over the slowest quarter of the match, which is + closer to what stutter feels like than the headline average. +- `hitch` counts ten-second samples whose frame time is at least twice the + match median. +- FPS depends on map, graphics settings, charger/power state, other programs and + shader-cache warmth. Treat comparisons as useful only when those match. + +## Verified configurations + +| Distro / desktop | CPU / GPU | Driver | Session | Kernel | Runtime | Result | Frame pacing | +|---|---|---|---|---|---|---|---| +| Omarchy (Arch), Hyprland | Intel i5-10210U / Intel UHD 620 | `i915`, Mesa ANV | Wayland + Xwayland | 7.2.5 | patched Darling, `-O2` libc/libc++ | Client, game, ARAM/Swiftplay and store work; playable at low settings | 24–32 avg FPS over five ARAM matches; p99 44–98 ms; slowest quarter 20–27 FPS | + +Notes for the UHD 620 row: + +- Measured 2026-10-23 with `tools/perf-baseline.py` on five match logs, no + FPS overlay. The laptop has 11.5 GiB RAM and a 15 W Comet Lake CPU. +- The 24 FPS match is also the longest log and the one with the most samples; + the spread between matches is larger than the difference between recent + builds, so single-match comparisons are not meaningful. +- Intel panel self-refresh (`i915.enable_psr`) was on for part of this period. + See [panel self-refresh](TROUBLESHOOTING.md#blocky-video-or-flickering-startup-logo). +- Expect first-match shader compilation to show up as a low `p99` until the + cache is warm. + +The Ryzen 5 5500 / RX 6600 development machine is faster and is documented +separately in [status](STATUS.md#game-fps-2026-10-1314). It is not a laptop +baseline and should not be read as the expected result on integrated graphics. + +## Not verified + +These are common laptop configurations without a confirmed play test here. +Reports are welcome, but treat them as unknown rather than supported: + +- Recent AMD integrated graphics (Radeon 680M/780M) under RADV. +- NVIDIA hybrid laptops using PRIME render offload. +- Fedora Silverblue and other immutable distributions with Nix. +- Intel Arc discrete GPUs. + +Known driver caveats live in the +[troubleshooting guide](TROUBLESHOOTING.md#nvidia) and +[hybrid graphics notes](TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia). diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f4a5488..1f017de 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -1,5 +1,31 @@ # Troubleshooting +## Before reporting a problem + +Run the host checks first; they start nothing and work while a client is open: + +```sh +darling-lol --doctor +``` + +It reports the GPU/driver, Vulkan, display session, memory, disk, overlay layer +and prefix state, and ends with the checks that need attention. If you open an +issue, attach the machine-readable report and a crash bundle instead of a +hand-copied log: + +```sh +darling-lol --doctor --report doctor.json +darling-lol --report # ~/.cache/darling-lol/reports/*.tar.gz +darling-lol --perf # frame pacing from the game's own logs +``` + +The crash bundle contains the doctor checks, the process table, the runtime and +launcher log tails, graphics kernel messages, display state and recent core +dumps. It does not collect environment variables, tokens or passwords, and it +replaces your home directory, user name and host name with placeholders unless +you pass `--no-redact`. See [compatibility](COMPATIBILITY.md) for what the +performance numbers mean and for verified machines. + ## NixOS launcher fails before Riot opens A message saying Darling must be setuid root is not fixed by running `sudo` diff --git a/tests/test_crash_report.py b/tests/test_crash_report.py new file mode 100644 index 0000000..bd1b64e --- /dev/null +++ b/tests/test_crash_report.py @@ -0,0 +1,166 @@ +"""Crash/hang bundle: process table, log tails, redaction; never starts anything.""" +import importlib.util +import io +import json +import os +import tarfile +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'tools/crash-report.py' +SPEC = importlib.util.spec_from_file_location('crash_report', SCRIPT) +crash_report = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(crash_report) + + +def make_process(proc, pid, ppid, state, comm, argv): + folder = Path(proc) / str(pid) + folder.mkdir(parents=True) + (folder / 'stat').write_text('%d (%s) %s %d 0 0 0\n' % (pid, comm, state, ppid)) + (folder / 'comm').write_text(comm + '\n') + (folder / 'cmdline').write_bytes(b'\0'.join(part.encode() for part in argv) + b'\0') + return folder + + +class ProcessSnapshotTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.proc = Path(self.folder.name) + make_process(self.proc, 100, 1, 'S', 'systemd', ['/usr/lib/systemd/systemd', '--user']) + make_process(self.proc, 101, 100, 'S', 'mldr', ['/Volumes/mldr', 'RiotClientServices']) + make_process(self.proc, 102, 101, 'Z', 'League of Legends', ['/game/League of Legends']) + (self.proc / 'self').mkdir() + (self.proc / 'uptime').write_text('1.0 2.0\n') + + def tearDown(self): + self.folder.cleanup() + + def test_snapshot_parses_stat_with_spaces_in_comm(self): + rows = crash_report.process_snapshot(self.proc) + self.assertEqual([row['pid'] for row in rows], [100, 101, 102]) + game = rows[-1] + self.assertEqual(game['comm'], 'League of Legends') + self.assertEqual(game['ppid'], 101) + self.assertEqual(game['state'], 'Z') + self.assertIn('League of Legends', game['argv']) + + def test_relevant_selects_runtime_client_and_zombies(self): + rows = crash_report.process_snapshot(self.proc) + relevant = crash_report.relevant_processes(rows) + self.assertEqual([row['pid'] for row in relevant], [101, 102]) + + def test_format_has_both_sections(self): + rows = crash_report.process_snapshot(self.proc) + text = crash_report.format_processes(rows, crash_report.relevant_processes(rows)) + self.assertIn('# all processes owned by this user', text) + self.assertIn('# runtime, client and zombie processes', text) + self.assertIn('101\t100\tS\tmldr', text) + + +class HelperTests(unittest.TestCase): + def test_scrub_replaces_identity(self): + with tempfile.TemporaryDirectory() as folder: + home = Path(folder) + text = '%s/secret joe on desktop-1' % home + cleaned = crash_report.scrub(text, home=home, username='joe', hostname='desktop-1') + self.assertEqual(cleaned, '~/secret on ') + self.assertEqual(crash_report.scrub('', home=home), '') + + def test_read_tail_keeps_last_lines(self): + with tempfile.TemporaryDirectory() as folder: + log = Path(folder) / 'dserver.log' + log.write_text(''.join('line %d\n' % index for index in range(10))) + text, error = crash_report.read_tail(log, limit=3) + self.assertIsNone(error) + self.assertEqual(text, 'line 7\nline 8\nline 9\n') + + def test_read_tail_reports_missing_file(self): + text, error = crash_report.read_tail('/nonexistent/dserver.log') + self.assertIsNone(text) + self.assertIn('No such file', error) + + def test_human_size(self): + self.assertEqual(crash_report.human_size(512), '512 B') + self.assertEqual(crash_report.human_size(2048), '2 KiB') + self.assertEqual(crash_report.human_size(3 * 1024 ** 2), '3 MiB') + + def test_bundle_name_has_label(self): + name = crash_report.bundle_name('hang') + self.assertTrue(name.startswith('lol-report-')) + self.assertTrue(name.endswith('-hang.tar.gz')) + + +class BuildTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.root = Path(self.folder.name) + self.prefix = self.root / 'prefix' + self.proc = self.root / 'proc' + self.out = self.root / 'reports' + self.proc.mkdir() + make_process(self.proc, 200, 1, 'S', 'darlingserver', ['/usr/libexec/darlingserver']) + make_process(self.proc, 201, 200, 'Z', 'mldr', ['/mldr']) + log = self.prefix / 'private/var/log/dserver.log' + log.parent.mkdir(parents=True) + log.write_text(str(Path.home()) + '/.darling running\nsecret marker\n') + self.launch = self.root / 'launch.log' + self.launch.write_text('launcher line\n') + self.env = {'USER': 'joe', 'HOME': str(Path.home()), 'SECRET_TOKEN': 'do-not-publish'} + + def tearDown(self): + self.folder.cleanup() + + def build(self, **overrides): + options = dict(prefix=self.prefix, out_dir=self.out, launch_log=self.launch, + proc_root=self.proc, which=lambda name: None, env=self.env) + options.update(overrides) + return crash_report.build(**options) + + def test_bundle_contains_expected_files_and_summary(self): + archive, manifest = self.build() + self.assertTrue(archive.is_file()) + self.assertEqual(manifest['processes'], {'total': 2, 'relevant': 2, 'zombies': 1}) + self.assertEqual(manifest['redacted'], True) + self.assertEqual(manifest['prefix'], '') + with tarfile.open(archive) as tar: + names = set(tar.getnames()) + self.assertIn('lol-report/README.txt', names) + self.assertIn('lol-report/report.json', names) + self.assertIn('lol-report/processes.txt', names) + self.assertIn('lol-report/logs/dserver.log.tail', names) + self.assertIn('lol-report/logs/kernel.txt', names) + self.assertIn('lol-report/coredumps.txt', names) + report = json.loads(tar.extractfile('lol-report/report.json').read()) + self.assertEqual(report['doctor']['checks'][0]['id'], 'kernel') + + def test_bundle_redacts_home_and_secrets(self): + archive, _ = self.build() + with tarfile.open(archive) as tar: + combined = '' + for member in tar.getmembers(): + if member.isfile(): + combined += tar.extractfile(member).read().decode() + self.assertNotIn(str(Path.home()), combined) + self.assertNotIn('do-not-publish', combined) + self.assertIn('~/.darling running', combined) + + def test_no_redact_keeps_paths(self): + archive, manifest = self.build(redact=False) + self.assertEqual(manifest['redacted'], False) + self.assertEqual(manifest['prefix'], str(self.prefix)) + with tarfile.open(archive) as tar: + text = tar.extractfile('lol-report/logs/dserver.log.tail').read().decode() + self.assertIn(str(Path.home()), text) + + def test_missing_logs_are_explained(self): + archive, manifest = self.build(launch_log=None) + self.assertIn('logs/launch.log.tail', manifest['files']) + with tarfile.open(archive) as tar: + text = tar.extractfile('lol-report/logs/launch.log.tail').read().decode() + self.assertIn('no launcher log', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..5261ee6 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,215 @@ +"""Host checks used by `darling-lol --doctor`; never starts the client or runtime.""" +import contextlib +import importlib.util +import io +import json +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'tools/doctor.py' +SPEC = importlib.util.spec_from_file_location('doctor', SCRIPT) +doctor = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(doctor) + +GIB = 1024 ** 3 + + +def facts(**overrides): + base = { + 'host': {'distro': 'Test', 'kernel': '6.8.0', 'arch': 'x86_64', 'python': '3.12.0'}, + 'session': {'type': 'wayland', 'desktop': 'Hyprland', 'display': ':0', 'wayland_display': 'wayland-1'}, + 'memory': {'total': 16 * GIB, 'swap': 8 * GIB, 'total_gib': 16.0, 'swap_gib': 8.0}, + 'disk': {'free': 100 * GIB, 'free_gib': 100.0}, + 'gpus': [{'card': 'card0', 'driver': 'i915', 'vendor_id': '0x8086', + 'device_id': '0x9b41', 'name': 'UHD 620'}], + 'vulkan_icds': ['/usr/share/vulkan/icd.d/intel_icd.json'], + 'prefix': '/tmp/prefix', + 'prefix_exists': True, + 'prefix_launchd': True, + 'prefix_socket': True, + 'prefix_state': {'pid': 0, 'state': 'absent', 'stale_markers': 0}, + 'psr': '0', + 'core_limit': '0', + 'environment': {}, + 'tools': {}, + } + base.update(overrides) + return base + + +class CheckTests(unittest.TestCase): + def test_kernel_rejects_old_release(self): + self.assertEqual(doctor.check_kernel(facts(host={'kernel': '5.4.0'}))[0], 'warn') + self.assertEqual(doctor.check_kernel(facts(host={'kernel': '6.8.0-arch1'}))[0], 'ok') + self.assertEqual(doctor.check_kernel(facts(host={'kernel': 'unparsed'}))[0], 'warn') + + def test_memory_requires_minimum(self): + low = facts(memory={'total': 2 * GIB, 'swap': 0, 'total_gib': 2.0, 'swap_gib': 0.0}) + tight = facts(memory={'total': 6 * GIB, 'swap': 0, 'total_gib': 6.0, 'swap_gib': 0.0}) + self.assertEqual(doctor.check_memory(low)[0], 'fail') + self.assertEqual(doctor.check_memory(tight)[0], 'warn') + self.assertEqual(doctor.check_memory(facts())[0], 'ok') + unknown = facts(memory={'total': 0, 'swap': 0, 'total_gib': 0.0, 'swap_gib': 0.0}) + self.assertEqual(doctor.check_memory(unknown)[0], 'ok') + + def test_disk_space(self): + self.assertEqual(doctor.check_disk(facts(disk={'free': 1 * GIB, 'free_gib': 1.0}))[0], 'fail') + self.assertEqual(doctor.check_disk(facts(disk={'free': 10 * GIB, 'free_gib': 10.0}))[0], 'warn') + self.assertEqual(doctor.check_disk(facts())[0], 'ok') + + def test_gpu_driver_reports_card(self): + status, message = doctor.check_gpu(facts()) + self.assertEqual(status, 'ok') + self.assertIn('UHD 620 (i915)', message) + self.assertEqual(doctor.check_gpu(facts(gpus=[]))[0], 'fail') + nouveau = facts(gpus=[{'card': 'card0', 'driver': 'nouveau', 'vendor_id': '0x10de', + 'device_id': '0x1f91', 'name': 'GTX 1650'}]) + self.assertEqual(doctor.check_gpu(nouveau)[0], 'warn') + + def test_vulkan_icds(self): + self.assertEqual(doctor.check_vulkan(facts())[0], 'ok') + status, message = doctor.check_vulkan(facts(vulkan_icds=[])) + self.assertEqual(status, 'fail') + self.assertIn('Mesa', message) + + def test_session_needs_display_on_wayland(self): + self.assertEqual(doctor.check_session(facts())[0], 'ok') + missing = facts(session={'type': 'wayland', 'desktop': '', 'display': '', 'wayland_display': 'wayland-1'}) + self.assertEqual(doctor.check_session(missing)[0], 'fail') + x11 = facts(session={'type': 'x11', 'desktop': 'i3', 'display': ':1', 'wayland_display': ''}) + self.assertEqual(doctor.check_session(x11)[0], 'ok') + bare = facts(session={'type': '', 'desktop': '', 'display': '', 'wayland_display': ''}) + self.assertEqual(doctor.check_session(bare)[0], 'warn') + + def test_mangohud_is_a_failure(self): + self.assertEqual(doctor.check_hud(facts(environment={'MANGOHUD': '1'}))[0], 'fail') + self.assertEqual(doctor.check_hud(facts(environment={'DISABLE_MANGOHUD': '1'}))[0], 'ok') + self.assertEqual(doctor.check_hud(facts())[0], 'ok') + + def test_core_dumps_and_psr(self): + self.assertEqual(doctor.check_core_dumps(facts(core_limit='unlimited'))[0], 'warn') + self.assertEqual(doctor.check_core_dumps(facts(core_limit='0'))[0], 'ok') + self.assertEqual(doctor.check_psr(facts(psr='1'))[0], 'warn') + self.assertEqual(doctor.check_psr(facts(psr='0'))[0], 'ok') + + def test_prefix_must_be_installed(self): + self.assertEqual(doctor.check_prefix(facts(prefix_exists=False))[0], 'fail') + self.assertEqual(doctor.check_prefix(facts(prefix_launchd=False))[0], 'fail') + self.assertEqual(doctor.check_prefix(facts())[0], 'ok') + + def test_runtime_states(self): + self.assertEqual(doctor.check_runtime(facts(prefix_state={'pid': 42, 'state': 'running', + 'stale_markers': 0}))[0], 'ok') + self.assertEqual(doctor.check_runtime(facts(prefix_state={'pid': 42, 'state': 'stale', + 'stale_markers': 0}))[0], 'warn') + self.assertEqual(doctor.check_runtime(facts(prefix_state={'pid': 0, 'state': 'absent', + 'stale_markers': 0}))[0], 'ok') + + def test_stale_marker_count(self): + self.assertEqual(doctor.check_stale_markers( + facts(prefix_state={'pid': 0, 'state': 'absent', 'stale_markers': 2}))[0], 'ok') + self.assertEqual(doctor.check_stale_markers( + facts(prefix_state={'pid': 0, 'state': 'absent', 'stale_markers': 10}))[0], 'warn') + + +class PrefixStateTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.root = Path(self.folder.name) + self.prefix = self.root / 'prefix' + self.prefix.mkdir() + self.proc = self.root / 'proc' + self.proc.mkdir() + + def tearDown(self): + self.folder.cleanup() + + def test_absent(self): + self.assertEqual(doctor.prefix_state(self.prefix, self.proc)['state'], 'absent') + + def test_running(self): + (self.prefix / '.init.pid').write_text('123\n') + status = self.proc / '123' + status.mkdir() + (status / 'status').write_text('Name:\tdarlingserver\nState:\tS (sleeping)\n') + self.assertEqual(doctor.prefix_state(self.prefix, self.proc)['state'], 'running') + + def test_zombie_and_missing_pid_are_stale(self): + (self.prefix / '.init.pid').write_text('123\n') + status = self.proc / '123' + status.mkdir() + (status / 'status').write_text('Name:\tdarlingserver\nState:\tZ (zombie)\n') + self.assertEqual(doctor.prefix_state(self.prefix, self.proc)['state'], 'stale') + (status / 'status').unlink() + status.rmdir() + self.assertEqual(doctor.prefix_state(self.prefix, self.proc)['state'], 'stale') + + def test_stale_markers_are_counted(self): + (self.prefix / '.init.pid.stale-1-2').write_text('1') + (self.prefix / '.init.pid.stale-3-4').write_text('3') + self.assertEqual(doctor.prefix_state(self.prefix, self.proc)['stale_markers'], 2) + + +class GpuAndVulkanTests(unittest.TestCase): + def test_read_gpus_follows_driver_link(self): + with tempfile.TemporaryDirectory() as folder: + sys_root = Path(folder) + card = sys_root / 'class/drm/card0/device' + card.mkdir(parents=True) + (card / 'vendor').write_text('0x8086\n') + (card / 'device').write_text('0x9b41\n') + (card / 'product_name').write_text('Intel UHD 620\n') + drivers = sys_root / 'drivers/i915' + drivers.mkdir(parents=True) + (card / 'driver').symlink_to(drivers) + gpus = doctor.read_gpus(sys_root) + self.assertEqual(gpus, [{'card': 'card0', 'driver': 'i915', 'vendor_id': '0x8086', + 'device_id': '0x9b41', 'name': 'Intel UHD 620'}]) + + def test_vulkan_icds_deduplicate(self): + with tempfile.TemporaryDirectory() as folder: + icd = Path(folder) / 'vulkan/icd.d' + icd.mkdir(parents=True) + (icd / 'intel.json').write_text('{}') + found = doctor.vulkan_icds({'XDG_DATA_DIRS': folder}) + self.assertIn(str(icd / 'intel.json'), found) + self.assertEqual(len(found), len(set(found))) + + +class ReportTests(unittest.TestCase): + def test_run_checks_isolates_a_broken_probe(self): + results = doctor.run_checks({'host': {}}) + by_id = {result['id']: result for result in results} + self.assertEqual(len(results), len(doctor.CHECKS)) + self.assertEqual(by_id['kernel']['status'], 'warn') + self.assertIn('could not run', by_id['kernel']['message']) + + def test_summary_counts(self): + results = [{'status': 'ok'}, {'status': 'ok'}, {'status': 'warn'}, {'status': 'fail'}] + self.assertEqual(doctor.summarize(results), {'ok': 2, 'warn': 1, 'fail': 1}) + + def test_report_has_no_environment_values(self): + data = facts(environment={'DISPLAY': ':0', 'RCLOL_REGION': 'EUW'}) + document = doctor.report(data, doctor.run_checks(data)) + self.assertEqual(document['schema'], doctor.SCHEMA) + self.assertEqual(document['environment'], {'DISPLAY': ':0', 'RCLOL_REGION': 'EUW'}) + self.assertNotIn('MANGOHUD', document['environment']) + + def test_cli_writes_json(self): + with tempfile.TemporaryDirectory() as folder: + prefix = Path(folder) / 'prefix' + prefix.mkdir() + out = Path(folder) / 'doctor.json' + stream = io.StringIO() + with contextlib.redirect_stdout(stream): + code = doctor.main(['--prefix', str(prefix), '--report', str(out)]) + self.assertIn(code, (0, 1)) + document = json.loads(out.read_text()) + self.assertEqual(document['tool'], 'darling-lol doctor') + self.assertIn('checks', document) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_perf_baseline.py b/tests/test_perf_baseline.py new file mode 100644 index 0000000..13116fa --- /dev/null +++ b/tests/test_perf_baseline.py @@ -0,0 +1,150 @@ +"""Frame-pacing baseline from League's own netstats.csv logs; reads files only.""" +import contextlib +import csv +import importlib.util +import io +import json +import os +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / 'tools/perf-baseline.py' +SPEC = importlib.util.spec_from_file_location('perf_baseline', SCRIPT) +perf = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(perf) + +HEADER = ['game.frametime_average', 'game.frametime_variance', 'game.time', + 'network.rtt_average', 'network.packets_lost'] + + +def write_match(folder, frametimes, name='2026-10-23T17-03-29'): + target = Path(folder) / name / ('%s_netstats.csv' % name) + target.parent.mkdir(parents=True, exist_ok=True) + with open(target, 'w', newline='') as handle: + writer = csv.writer(handle) + writer.writerow(HEADER) + for index, frametime in enumerate(frametimes): + writer.writerow([frametime, 0.001, index * 10.0, 30.0, 0]) + return target + + +class MeasureTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.root = Path(self.folder.name) + + def tearDown(self): + self.folder.cleanup() + + def test_metrics(self): + path = write_match(self.root, [0.03] * 6 + [0.06, 0.09]) + result = perf.measure(path) + self.assertEqual(result['match'], '2026-10-23 17:03') + self.assertEqual(result['samples'], 8) + self.assertEqual(result['seconds'], 70.0) + self.assertEqual(result['avg_fps'], 24.2) + self.assertEqual(result['p50_ms'], 30.0) + self.assertEqual(result['p95_ms'], 90.0) + self.assertEqual(result['slowest_quarter_fps'], 13.3) + self.assertEqual(result['slowest_quarter_ms'], 75.0) + self.assertEqual(result['hitches'], 2) + + def test_bad_and_empty_rows_are_skipped(self): + path = self.root / '2026-01-01T00-00-00' / '2026-01-01T00-00-00_netstats.csv' + path.parent.mkdir() + path.write_text(','.join(HEADER) + '\n' + ',0,0,0,0\n' + '0.000,0,0,0,0\n' + 'nan,0,0,0,0\n' + '0.050,0,10,0,0\n' + 'oops,0,20,0,0\n') + result = perf.measure(path) + self.assertEqual(result['samples'], 1) + self.assertEqual(result['avg_fps'], 20.0) + + def test_rows_without_frametime_return_none(self): + path = self.root / '2026-01-01T00-00-00' / '2026-01-01T00-00-00_netstats.csv' + path.parent.mkdir() + path.write_text(','.join(HEADER) + '\n') + self.assertIsNone(perf.measure(path)) + + def test_percentile_bounds(self): + self.assertEqual(perf.percentile([], 0.5), 0.0) + self.assertEqual(perf.percentile([3, 1, 2], 0.0), 1) + self.assertEqual(perf.percentile([3, 1, 2], 0.5), 2) + self.assertEqual(perf.percentile([3, 1, 2], 1.0), 3) + + def test_match_label_falls_back_to_folder(self): + self.assertEqual(perf.match_label('/tmp/odd-folder/file.csv'), 'odd-folder') + + +class DiscoveryTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.prefix = Path(self.folder.name) + self.logs = self.prefix / perf.DEFAULT_GAME_LOGS + self.old = write_match(self.logs, [0.03], '2026-10-21T10-00-00') + self.new = write_match(self.logs, [0.03], '2026-10-23T10-00-00') + os.utime(self.old, (1000, 1000)) + os.utime(self.new, (2000, 2000)) + unrelated = self.logs / '2026-10-22T10-00-00' / 'other.csv' + unrelated.parent.mkdir(parents=True) + unrelated.write_text('x') + + def tearDown(self): + self.folder.cleanup() + + def test_newest_first_and_only_netstats(self): + self.assertEqual(perf.discover(self.prefix), [self.new, self.old]) + + +class OutputTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.root = Path(self.folder.name) + self.path = write_match(self.root, [0.04, 0.04, 0.04, 0.08]) + self.result = perf.measure(self.path) + + def tearDown(self): + self.folder.cleanup() + + def test_render_table(self): + text = perf.render([self.result]) + self.assertIn('avgfps', text) + self.assertIn('2026-10-23 17:03', text) + self.assertIn('hitch', text) + + def test_render_empty_explains_expected_path(self): + text = perf.render([]) + self.assertIn('No netstats.csv files found', text) + self.assertIn('Applications/League of Legends.app', text) + + def test_append_writes_header_once(self): + path = self.root / 'baseline.csv' + perf.append_csv([self.result], path) + perf.append_csv([self.result], path) + with open(path, newline='') as handle: + rows = list(csv.DictReader(handle)) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]['match'], '2026-10-23 17:03') + self.assertEqual(rows[0]['avg_fps'], '20.0') + self.assertEqual(path.read_text().count('measured,match'), 1) + + def test_json_cli(self): + stream = io.StringIO() + with contextlib.redirect_stdout(stream): + code = perf.main(['--csv', str(self.path), '--json']) + self.assertEqual(code, 0) + document = json.loads(stream.getvalue()) + self.assertEqual(document['matches'][0]['samples'], 4) + + def test_cli_without_matches_returns_one(self): + missing = self.root / 'missing.csv' + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(perf.main(['--csv', str(missing)]), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/crash-report.py b/tools/crash-report.py new file mode 100644 index 0000000..440e3f6 --- /dev/null +++ b/tools/crash-report.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Bundle the diagnostics needed to explain a Darling/League crash or hang. + +The bundle never starts or stops anything. It collects the host report from +`doctor.py`, the Riot/Darling process table, the tail of the runtime and +launcher logs, recent kernel messages and the last core dumps, and writes one +gzipped tarball that can be attached to an issue. + +Home directory, user name and host name are replaced by placeholders by +default; environment variables are an allowlist, so tokens are never included. + +Usage: + python3 tools/crash-report.py + python3 tools/crash-report.py --out ~/reports --prefix ~/.darling + python3 tools/crash-report.py --no-redact --json +""" +import argparse +import json +import os +import platform +import re +import shutil +import socket +import subprocess +import sys +import tarfile +import tempfile +import time +from pathlib import Path + +TOOLS = Path(__file__).resolve().parent +if str(TOOLS) not in sys.path: + sys.path.insert(0, str(TOOLS)) + +import doctor # noqa: E402 (same directory as this script) + +LOG_TAIL_LINES = 4000 +KERNEL_PATTERN = re.compile( + r'i915|amdgpu|radeon|\bxe\b|drm|vulkan|GPU HANG|out of memory|oom-kill|segfault|traps:', re.I) +PROCESS_NAMES = ( + 'darlingserver', 'mldr', 'launchd', 'RiotClientServices', 'LeagueClientUx', + 'LeagueClient', 'League of Legends', 'CrBrowserMain', 'CrGpuMain', 'CrRendererMain', + 'CrUtilityMain', 'wine', 'gamescope', 'Xwayland', +) + + +def read_tail(path, limit=LOG_TAIL_LINES): + try: + with open(path, 'r', errors='replace') as handle: + lines = handle.readlines() + except OSError as error: + return None, str(error) + return ''.join(lines[-limit:]), None + + +def process_snapshot(proc_root=Path('/proc')): + """`ps`-like table for this user plus every zombie, without calling ps.""" + rows = [] + uid = os.getuid() + for entry in Path(proc_root).iterdir(): + if not entry.name.isdigit(): + continue + try: + stat = (entry / 'stat').read_text() + # Field 22 onwards follows the last ')' because comm may contain spaces. + rest = stat.rsplit(')', 1)[1].split() + state = rest[0] + ppid = int(rest[1]) + owner = entry.stat().st_uid + if owner != uid and state != 'Z': + continue + comm = (entry / 'comm').read_text().strip() + argv = (entry / 'cmdline').read_bytes().replace(b'\0', b' ').decode(errors='replace').strip() + except (OSError, ValueError, IndexError): + continue + rows.append({ + 'pid': int(entry.name), + 'ppid': ppid, + 'state': state, + 'comm': comm, + 'argv': argv[:300], + }) + return sorted(rows, key=lambda row: (row['pid'])) + + +def relevant_processes(rows): + """Processes worth reading first: the runtime, the client and leftovers.""" + selected = [] + for row in rows: + if row['state'] == 'Z': + selected.append(row) + elif any(name in row['comm'] or name in row['argv'] for name in PROCESS_NAMES): + selected.append(row) + return selected + + +def format_processes(rows, relevant): + lines = ['# all processes owned by this user, plus zombies', + 'pid\tppid\tstate\tcomm\targv'] + for row in rows: + lines.append('%s\t%s\t%s\t%s\t%s' % (row['pid'], row['ppid'], row['state'], row['comm'], row['argv'])) + lines.append('') + lines.append('# runtime, client and zombie processes') + for row in relevant: + lines.append('%s\t%s\t%s\t%s\t%s' % (row['pid'], row['ppid'], row['state'], row['comm'], row['argv'])) + return '\n'.join(lines) + '\n' + + +def run_command(command, timeout=10): + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False) + except (OSError, subprocess.SubprocessError) as error: + return None, str(error) + if result.returncode != 0: + return (result.stdout or ''), (result.stderr or 'exit %d' % result.returncode).strip() + return result.stdout, None + + +def kernel_messages(which): + if not which('dmesg'): + return '# dmesg is not installed; kernel messages are unavailable\n' + output, error = run_command(['dmesg', '--ctime']) + if error and not output: + return '# dmesg is not readable: %s\n' % error + lines = [line for line in output.splitlines() if KERNEL_PATTERN.search(line)] + return '\n'.join(lines[-300:]) + '\n' if lines else '# no graphics-related kernel messages\n' + + +def coredumps(which): + if not which('coredumpctl'): + return '# coredumpctl is not installed\n' + output, error = run_command(['coredumpctl', '--no-pager', 'list', '-n', '10']) + if error and not output: + return '# coredumpctl failed: %s\n' % error + return output or '# no core dumps\n' + + +def display_state(which, env): + if env.get('WAYLAND_DISPLAY') and which('hyprctl'): + output, error = run_command(['hyprctl', 'monitors', '-j']) + clients, client_error = run_command(['hyprctl', 'clients', '-j']) + return '# hyprctl monitors\n%s\n# hyprctl clients\n%s\n%s' % ( + output or error, clients or client_error, '') + if which('xrandr') and env.get('DISPLAY'): + output, error = run_command(['xrandr', '--query']) + if output: + return output + return '# no display tool available; DISPLAY=%r WAYLAND_DISPLAY=%r\n' % ( + env.get('DISPLAY'), env.get('WAYLAND_DISPLAY')) + + +def scrub(text, home=None, username=None, hostname=None): + if not text: + return text + replacements = [ + (str(home or Path.home()), '~'), + (username or os.environ.get('USER') or '', ''), + (hostname or socket.gethostname(), ''), + ] + for value, placeholder in replacements: + if value: + text = text.replace(value, placeholder) + return text + + +def bundle_name(label=None): + stamp = time.strftime('%Y%m%d-%H%M%S', time.localtime()) + return 'lol-report-%s%s.tar.gz' % (stamp, '-' + label if label else '') + + +def build(prefix, out_dir, launch_log=None, label=None, redact=True, + proc_root=Path('/proc'), which=None, env=None): + """Write the bundle and return its path and manifest.""" + env = dict(os.environ if env is None else env) + which = which or shutil.which + prefix = Path(prefix or env.get('DPREFIX') or (Path.home() / '.darling')).expanduser() + out_dir = Path(out_dir).expanduser() + out_dir.mkdir(parents=True, exist_ok=True) + archive = out_dir / bundle_name(label) + + facts = doctor.gather(prefix=prefix, env=env, proc_root=proc_root, which=which) + checks = doctor.run_checks(facts) + rows = process_snapshot(proc_root) + relevant = relevant_processes(rows) + + dserver_log = Path(prefix) / 'private/var/log/dserver.log' + log_text, log_error = read_tail(dserver_log) + launch_text, launch_error = (read_tail(launch_log) if launch_log else (None, 'no launcher log')) + + entries = { + 'processes.txt': format_processes(rows, relevant), + 'logs/dserver.log.tail': log_text or '# unavailable: %s\n' % log_error, + 'logs/launch.log.tail': launch_text or '# unavailable: %s\n' % launch_error, + 'logs/kernel.txt': kernel_messages(which), + 'display.txt': display_state(which, env), + 'coredumps.txt': coredumps(which), + } + if redact: + entries = {name: scrub(text, username=env.get('USER')) for name, text in entries.items()} + checks = [ + dict(check, title=scrub(check['title'], username=env.get('USER')), + message=scrub(check['message'], username=env.get('USER'))) + for check in checks + ] + + manifest = { + 'schema': doctor.SCHEMA, + 'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + 'host': {key: facts['host'][key] for key in ('distro', 'kernel', 'arch')}, + 'python': platform.python_version(), + 'prefix': '' if redact else facts['prefix'], + 'redacted': redact, + 'doctor': {'summary': doctor.summarize(checks), 'checks': checks}, + 'processes': {'total': len(rows), 'relevant': len(relevant), 'zombies': sum(1 for row in rows if row['state'] == 'Z')}, + 'files': sorted(entries), + } + if redact: + manifest['host']['distro'] = scrub(facts['host']['distro'], username=env.get('USER')) + + with tempfile.TemporaryDirectory(prefix='lol-report-') as temporary: + root = Path(temporary) + (root / 'logs').mkdir() + (root / 'report.json').write_text(json.dumps(manifest, indent=2) + '\n') + for name, text in entries.items(): + (root / name).write_text(text) + (root / 'README.txt').write_text(readme(manifest)) + with tarfile.open(archive, 'w:gz') as tar: + for path in sorted(root.rglob('*')): + if path.is_file(): + tar.add(path, arcname=str(Path('lol-report') / path.relative_to(root))) + + return archive, manifest + + +def readme(manifest): + return '''darling-lol crash report +======================== + +Created: %(created)s +Host: %(distro)s, kernel %(kernel)s (%(arch)s) +Prefix: %(prefix)s +Redacted: %(redacted)s + +What is inside: +- report.json: the doctor checks, host summary and process counts +- processes.txt: this user's processes plus every zombie, and the subset that is + part of the Darling runtime or the Riot/League client +- logs/dserver.log.tail: the last %(lines)d lines of the runtime log +- logs/launch.log.tail: the last lines the launcher wrote +- logs/kernel.txt: graphics-related kernel messages +- display.txt: monitors and windows (or an explanation when unavailable) +- coredumps.txt: recent systemd-coredump entries, if any + +Tokens, passwords and other environment secrets are not collected. Home +directory, user name and host name are replaced with placeholders unless the +bundle was created with --no-redact. +''' % { + 'created': manifest['created'], + 'distro': manifest['host']['distro'], + 'kernel': manifest['host']['kernel'], + 'arch': manifest['host']['arch'], + 'prefix': manifest['prefix'], + 'redacted': manifest['redacted'], + 'lines': LOG_TAIL_LINES, + } + + +def main(argv=None): + parser = argparse.ArgumentParser(description='Bundle Darling/League crash and hang diagnostics.') + parser.add_argument('--prefix', type=Path, help='Darling prefix (default: DPREFIX or ~/.darling)') + parser.add_argument('--out', type=Path, + default=Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'darling-lol/reports', + help='directory for the tarball (default: ~/.cache/darling-lol/reports)') + parser.add_argument('--launch-log', type=Path, help='launcher log to include') + parser.add_argument('--label', help='short label added to the file name') + parser.add_argument('--no-redact', action='store_true', help='keep the real home path, user and host name') + parser.add_argument('--json', action='store_true', help='print the manifest') + args = parser.parse_args(argv) + + launch_log = args.launch_log + if launch_log is None: + candidate = Path(__file__).resolve().parents[1] / 'build/lol-launch.log' + launch_log = candidate if candidate.is_file() else None + + archive, manifest = build( + prefix=args.prefix, + out_dir=args.out, + launch_log=launch_log, + label=args.label, + redact=not args.no_redact, + ) + if args.json: + print(json.dumps(manifest, indent=2)) + print('Report: %s (%s)' % (archive, human_size(archive.stat().st_size))) + summary = manifest['doctor']['summary'] + print('%d ok, %d warning(s), %d failure(s); %d zombie process(es)' % ( + summary['ok'], summary['warn'], summary['fail'], manifest['processes']['zombies'])) + print('Attach this file to the issue; it contains no tokens or environment secrets.') + return 0 + + +def human_size(size): + for unit in ('B', 'KiB', 'MiB'): + if size < 1024: + return '%.0f %s' % (size, unit) + size /= 1024 + return '%.1f GiB' % size + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/doctor.py b/tools/doctor.py new file mode 100644 index 0000000..7979f56 --- /dev/null +++ b/tools/doctor.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +"""Check whether this host can run darling-lol, and write a report to attach to an issue. + +The checks never start the game, the client or the runtime. They read host +information, the prefix and the environment, so the command is safe to run at +any time, including while a client is open. + +Usage: + python3 tools/doctor.py # human-readable summary + python3 tools/doctor.py --json # machine-readable report + python3 tools/doctor.py --report FILE # write the JSON report to FILE + +Exit status is 1 when a required check fails; warnings do not fail. +""" +import argparse +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path + +SCHEMA = 1 +# League of Legends, the Riot Client and shader caches need room to grow. A +# checkout/build needs much more, but that is reported by the installer. +MIN_FREE_DISK_GIB = 5 +WARN_FREE_DISK_GIB = 15 +MIN_RAM_GIB = 4 +WARN_RAM_GIB = 8 +MIN_KERNEL = (5, 10) +# A report is public when it is attached to an issue, so only the variables +# that describe the runtime environment are collected. +ENV_ALLOWLIST = ( + 'XDG_SESSION_TYPE', + 'XDG_CURRENT_DESKTOP', + 'WAYLAND_DISPLAY', + 'DISPLAY', + 'MANGOHUD', + 'DISABLE_MANGOHUD', + 'RCLOL_REGION', + 'RCLOL_LOCALE', + 'RCLOL_CDN_IPV4_ONLY', + 'RCLOL_RECOVER_STALE_CLIENT', + 'RCLOL_CLIENT_IN_GAME_TRICKS', + 'DPREFIX', + 'DARLING_WORKSPACE', +) + + +def read_text(path, default=''): + try: + return Path(path).read_text() + except OSError: + return default + + +def os_release(path='/etc/os-release'): + values = {} + for line in read_text(path).splitlines(): + if '=' in line: + key, value = line.split('=', 1) + values[key] = value.strip().strip('"') + return values + + +def meminfo(path='/proc/meminfo'): + values = {} + for line in read_text(path).splitlines(): + match = re.match(r'(\w+):\s+(\d+)', line) + if match: + values[match.group(1)] = int(match.group(2)) * 1024 + return values + + +def gib(value): + return round(value / (1024 ** 3), 1) + + +def kernel_tuple(release): + numbers = re.findall(r'^\d+(?:\.\d+)*', release) + if not numbers: + return (0,) + return tuple(int(part) for part in numbers[0].split('.')) + + +def read_gpus(sys_root=Path('/sys')): + """Return one entry per DRM card with its driver and PCI identity.""" + cards = [] + drm = Path(sys_root) / 'class/drm' + try: + entries = sorted(drm.iterdir()) + except OSError: + return cards + for entry in entries: + if not re.fullmatch(r'card\d+', entry.name): + continue + device = entry / 'device' + driver = '' + try: + driver = (device / 'driver').resolve().name + except OSError: + pass + vendor = read_text(device / 'vendor').strip() + product = read_text(device / 'device').strip() + name = '' + for candidate in (device / 'product_name', entry / 'product_name'): + name = read_text(candidate).strip() + if name: + break + cards.append({ + 'card': entry.name, + 'driver': driver, + 'vendor_id': vendor, + 'device_id': product, + 'name': name, + }) + return cards + + +def vulkan_icds(env=None): + """Vulkan ICD manifests the loader can see, including Nix's driver store.""" + env = env or os.environ + search = [ + '/usr/share/vulkan/icd.d', + '/usr/local/share/vulkan/icd.d', + '/run/opengl-driver/share/vulkan/icd.d', + '/etc/vulkan/icd.d', + os.path.join(env.get('XDG_DATA_DIRS', ''), 'vulkan/icd.d') if env.get('XDG_DATA_DIRS') else None, + ] + found = [] + for folder in filter(None, search): + try: + names = sorted(p.name for p in Path(folder).glob('*.json')) + except OSError: + names = [] + for name in names: + item = os.path.join(folder, name) + if item not in found: + found.append(item) + return found + + +def prefix_state(prefix, proc_root=Path('/proc')): + marker = Path(prefix) / '.init.pid' + state = 'absent' + pid = 0 + try: + pid = int(marker.read_text().strip()) + except (OSError, ValueError): + pid = 0 + if pid > 0: + status = Path(proc_root) / str(pid) / 'status' + text = read_text(status) + current = re.search(r'^State:\s+(\S)', text, re.M) + if not text: + state = 'stale' + elif current and current.group(1) == 'Z': + state = 'stale' + else: + state = 'running' + stale_markers = len(list(Path(prefix).glob('.init.pid.stale-*'))) + return {'pid': pid, 'state': state, 'stale_markers': stale_markers} + + +def gather(prefix=None, env=None, proc_root=Path('/proc'), sys_root=Path('/sys'), which=None): + """Collect facts once; checks below only interpret them and stay testable.""" + env = dict(os.environ if env is None else env) + which = which or shutil.which + prefix = Path(prefix or env.get('DPREFIX') or (Path.home() / '.darling')).expanduser() + + release = os_release() + memory = meminfo() + total = memory.get('MemTotal', 0) + swap = memory.get('SwapTotal', 0) + try: + free_disk = shutil.disk_usage(prefix if prefix.exists() else prefix.parent).free + except OSError: + free_disk = 0 + + socket = prefix / '.darlingserver.sock' + runtime_bin = Path('/usr/local/libexec/darling') + return { + 'host': { + 'distro': release.get('PRETTY_NAME') or platform.system(), + 'kernel': platform.release(), + 'arch': platform.machine(), + 'python': platform.python_version(), + }, + 'session': { + 'type': env.get('XDG_SESSION_TYPE', ''), + 'desktop': env.get('XDG_CURRENT_DESKTOP', ''), + 'display': env.get('DISPLAY', ''), + 'wayland_display': env.get('WAYLAND_DISPLAY', ''), + }, + 'memory': { + 'total': total, + 'swap': swap, + 'total_gib': gib(total), + 'swap_gib': gib(swap), + }, + 'disk': {'free': free_disk, 'free_gib': gib(free_disk)}, + 'gpus': read_gpus(sys_root), + 'vulkan_icds': vulkan_icds(env), + 'prefix': str(prefix), + 'prefix_exists': prefix.is_dir(), + 'prefix_launchd': (runtime_bin / 'sbin/launchd').is_file(), + 'prefix_socket': socket.exists(), + 'prefix_state': prefix_state(prefix, proc_root), + 'psr': read_text(sys_root / 'module/i915/parameters/enable_psr').strip(), + 'core_limit': _core_limit(), + 'environment': {key: env[key] for key in ENV_ALLOWLIST if key in env}, + 'tools': {name: which(name) for name in ('xprop', 'xdpyinfo', 'vulkaninfo', 'dmesg', 'coredumpctl')}, + } + + +def _core_limit(): + try: + import resource + soft, _ = resource.getrlimit(resource.RLIMIT_CORE) + return 'unlimited' if soft == resource.RLIM_INFINITY else str(soft) + except (ImportError, OSError, ValueError): + return 'unknown' + + +def check_kernel(facts): + if kernel_tuple(facts['host']['kernel']) < MIN_KERNEL: + return 'warn', 'Kernel %s is older than 5.10; Vulkan and DRM fixes may be missing' % facts['host']['kernel'] + return 'ok', 'Kernel %s' % facts['host']['kernel'] + + +def check_memory(facts): + total = facts['memory']['total'] + if total and total < MIN_RAM_GIB * 1024 ** 3: + return 'fail', 'Only %s GiB RAM; the client and game need more' % facts['memory']['total_gib'] + if total and total < WARN_RAM_GIB * 1024 ** 3: + return 'warn', '%s GiB RAM is tight; close other applications' % facts['memory']['total_gib'] + return 'ok', '%s GiB RAM, %s GiB swap' % (facts['memory']['total_gib'], facts['memory']['swap_gib']) + + +def check_disk(facts): + free = facts['disk']['free'] + if free < MIN_FREE_DISK_GIB * 1024 ** 3: + return 'fail', 'Only %s GiB free; the client needs a few GiB plus patches' % facts['disk']['free_gib'] + if free < WARN_FREE_DISK_GIB * 1024 ** 3: + return 'warn', '%s GiB free; large patches and shader caches need room' % facts['disk']['free_gib'] + return 'ok', '%s GiB free' % facts['disk']['free_gib'] + + +def check_gpu(facts): + gpus = facts['gpus'] + if not gpus: + return 'fail', 'No DRM card found; Darling needs a GPU with Vulkan' + described = [] + for gpu in gpus: + driver = gpu['driver'] or 'unknown driver' + name = gpu['name'] or 'GPU %s:%s' % (gpu['vendor_id'], gpu['device_id']) + described.append('%s (%s)' % (name, driver)) + if any(gpu['driver'] == 'nouveau' for gpu in gpus): + return 'warn', ', '.join(described) + '; nouveau has limited Vulkan support' + return 'ok', ', '.join(described) + + +def check_vulkan(facts): + icds = facts['vulkan_icds'] + if not icds: + return 'fail', 'No Vulkan ICD found; install the Mesa Vulkan driver (vulkan-radeon or vulkan-intel)' + names = ', '.join(Path(item).name for item in icds) + return 'ok', names + + +def check_session(facts): + session = facts['session'] + kind = (session['type'] or '').lower() + if kind == 'wayland' and not session['display']: + return 'fail', 'Wayland session without DISPLAY; the client needs Xwayland' + if kind == 'wayland': + return 'ok', 'Wayland through Xwayland (%s)' % session['display'] + if session['display']: + return 'ok', 'X11 (%s)' % session['display'] + return 'warn', 'No X11 DISPLAY; start the launcher from a desktop session' + + +def check_hud(facts): + environment = facts['environment'] + if environment.get('DISABLE_MANGOHUD') == '1': + return 'ok', 'MangoHud layer disabled for this launcher' + if environment.get('MANGOHUD'): + return 'fail', ('MANGOHUD is set; the overlay reads host /proc entries with guest PIDs and can abort ' + 'the client (issue #38). Unset it and set DISABLE_MANGOHUD=1') + return 'ok', 'No inherited MangoHud layer' + + +def check_core_dumps(facts): + if facts['core_limit'] == 'unlimited': + return 'warn', 'Core dumps are unlimited; a crashing guest can fill the disk' + return 'ok', 'Core dumps are bounded' + + +def check_psr(facts): + if facts['psr'] == '1': + return 'warn', ('i915 panel self-refresh is on; this can flicker or hitch the client. ' + 'Set options i915 enable_psr=0 in /etc/modprobe.d and reboot') + return 'ok', 'Panel self-refresh is off or not applicable' + + +def check_prefix(facts): + if not facts['prefix_exists']: + return 'fail', 'Prefix %s does not exist; run the installer first' % facts['prefix'] + if not facts['prefix_launchd']: + return 'fail', 'Installed launchd is missing; the runtime install did not complete' + return 'ok', facts['prefix'] + + +def check_runtime(facts): + state = facts['prefix_state'] + if state['state'] == 'running': + return 'ok', 'darlingserver is running (PID %s)' % state['pid'] + if state['state'] == 'stale': + return 'warn', 'Stale darlingserver PID marker; the next launch recovers it' + return 'ok', 'Runtime is stopped' + + +def check_stale_markers(facts): + count = facts['prefix_state']['stale_markers'] + if count > 3: + return 'warn', '%d stale runtime markers in the prefix; safe to delete' % count + return 'ok', 'No stale runtime markers' + + +CHECKS = ( + ('kernel', 'Kernel', check_kernel), + ('memory', 'Memory', check_memory), + ('disk', 'Disk space', check_disk), + ('gpu', 'GPU driver', check_gpu), + ('vulkan', 'Vulkan driver', check_vulkan), + ('session', 'Display session', check_session), + ('hud', 'Overlay layer', check_hud), + ('core', 'Core dumps', check_core_dumps), + ('psr', 'Panel self-refresh', check_psr), + ('prefix', 'Darling prefix', check_prefix), + ('runtime', 'Darling runtime', check_runtime), + ('markers', 'Runtime markers', check_stale_markers), +) + + +def run_checks(facts): + results = [] + for key, title, check in CHECKS: + try: + status, message = check(facts) + except Exception as error: # one broken probe must not hide the rest + status, message = 'warn', 'Check could not run: %s' % error + results.append({'id': key, 'title': title, 'status': status, 'message': message}) + return results + + +def summarize(results): + counts = {'ok': 0, 'warn': 0, 'fail': 0} + for result in results: + counts[result['status']] = counts.get(result['status'], 0) + 1 + return counts + + +def report(facts, results): + return { + 'schema': SCHEMA, + 'tool': 'darling-lol doctor', + 'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), + 'summary': summarize(results), + 'host': facts['host'], + 'session': facts['session'], + 'memory': facts['memory'], + 'disk': facts['disk'], + 'gpus': facts['gpus'], + 'vulkan_icds': facts['vulkan_icds'], + 'prefix': facts['prefix'], + 'environment': facts['environment'], + 'checks': results, + } + + +def render(facts, results): + lines = [] + host = facts['host'] + lines.append('darling-lol doctor - %s' % host['distro']) + lines.append(' kernel %s python %s %s' % (host['kernel'], host['python'], host['arch'])) + results_by_id = {result['id']: result for result in results} + for key in ('gpu', 'vulkan', 'session', 'memory', 'disk'): + lines.append(' %-8s %s' % (results_by_id[key]['title'] + ':', results_by_id[key]['message'])) + lines.append('') + for result in results: + if result['status'] == 'ok': + continue + lines.append('%-4s %s: %s' % (result['status'].upper(), result['title'], result['message'])) + counts = summarize(results) + if counts['fail'] or counts['warn']: + lines.append('') + lines.append('%d ok, %d warning(s), %d failure(s)' % (counts['ok'], counts['warn'], counts['fail'])) + if counts['fail']: + lines.append('Fix the failures above before reporting other problems.') + lines.append('Attach a full report with: darling-lol --doctor --report doctor.json') + return '\n'.join(lines) + + +def main(argv=None): + parser = argparse.ArgumentParser(description='Check this host for darling-lol and write an attachable report.') + parser.add_argument('--json', action='store_true', help='print the report as JSON') + parser.add_argument('--report', type=Path, help='write the JSON report to this file') + parser.add_argument('--prefix', type=Path, help='Darling prefix (default: DPREFIX or ~/.darling)') + args = parser.parse_args(argv) + + facts = gather(prefix=args.prefix) + results = run_checks(facts) + document = report(facts, results) + + if args.report: + args.report.write_text(json.dumps(document, indent=2) + '\n') + if args.json: + print(json.dumps(document, indent=2)) + else: + print(render(facts, results)) + if args.report: + print('Report written to %s' % args.report) + return 1 if document['summary']['fail'] else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/perf-baseline.py b/tools/perf-baseline.py new file mode 100644 index 0000000..528ffb5 --- /dev/null +++ b/tools/perf-baseline.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Measure frame pacing from the game's own `netstats.csv` logs. + +League writes one row per ten seconds of a match, including `game.frametime_average` +(seconds per frame) and `game.time`. This tool turns those rows into numbers that +can be compared before and after a performance change: + +- average FPS over the match, +- p50/p95/p99 frame time, +- average FPS of the slowest quarter of the match (the part you feel as stutter), +- the count of samples at least twice as slow as the match median (hitches). + +It never reads or starts the game; it only parses files that are already on disk. + +Usage: + python3 tools/perf-baseline.py # every match in the prefix + python3 tools/perf-baseline.py --last 5 # the five newest matches + python3 tools/perf-baseline.py --json + python3 tools/perf-baseline.py --append baseline.csv + python3 tools/perf-baseline.py --prefix /path/to/prefix --csv path/to/netstats.csv + +`--append` writes one row per match to a CSV file, so several runs on the same +laptop can be compared later without re-parsing the logs. +""" +import argparse +import csv +import json +import math +import os +import re +import statistics +import sys +import time +from pathlib import Path + +FRAMETIME_COLUMN = 'game.frametime_average' +TIME_COLUMN = 'game.time' +DEFAULT_GAME_LOGS = Path( + 'Applications/League of Legends.app/Contents/LoL/Logs/GameLogs') +HITCH_FACTOR = 2.0 +APPEND_HEADER = ( + 'measured', 'match', 'samples', 'seconds', 'avg_fps', 'mean_ms', + 'p50_ms', 'p95_ms', 'p99_ms', 'slowest_quarter_fps', 'slowest_quarter_ms', + 'hitches', 'host', +) + + +def default_prefix(): + return Path(os.environ.get('DPREFIX') or (Path.home() / '.darling')).expanduser() + + +def discover(prefix=None): + """Return netstats files, newest first, for a Darling prefix.""" + prefix = Path(prefix or default_prefix()).expanduser() + return sorted(Path(prefix, DEFAULT_GAME_LOGS).glob('*/*_netstats.csv'), + key=lambda path: path.stat().st_mtime, reverse=True) + + +def read_rows(path): + """Yield (game_time, frametime_seconds) for usable rows.""" + try: + handle = open(path, newline='') + except OSError: + return + with handle: + for row in csv.DictReader(handle): + try: + frametime = float(row.get(FRAMETIME_COLUMN) or '') + game_time = float(row.get(TIME_COLUMN) or 0) + except (TypeError, ValueError): + continue + if math.isfinite(frametime) and frametime > 0: + yield game_time, frametime + + +def match_label(path): + match = re.search(r'(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})', + Path(path).parent.name or Path(path).name) + if match: + return '%s %s:%s' % (match.group(1), match.group(2), match.group(3)) + return Path(path).parent.name + + +def percentile(values, fraction): + """Nearest-rank percentile; values may be unsorted.""" + if not values: + return 0.0 + ordered = sorted(values) + index = max(0, min(len(ordered) - 1, math.ceil(fraction * len(ordered)) - 1)) + return ordered[index] + + +def measure(path, measured=None): + """Return one match's metrics as a flat dict.""" + samples = list(read_rows(path)) + if not samples: + return None + times = [time_value for time_value, _ in samples] + frames = [frametime for _, frametime in samples] + seconds = max(times) - min(times) if len(times) > 1 else 10.0 * len(samples) + focus = sorted(frames)[-max(1, math.ceil(len(frames) / 4)):] + median = statistics.median(frames) + return { + 'measured': measured or time.strftime('%Y-%m-%dT%H:%M:%S'), + 'match': match_label(path), + 'path': str(path), + 'samples': len(frames), + 'seconds': round(seconds, 1), + 'avg_fps': round(len(frames) / sum(frames), 1), + 'mean_ms': round(statistics.mean(frames) * 1000, 2), + 'p50_ms': round(percentile(frames, 0.50) * 1000, 2), + 'p95_ms': round(percentile(frames, 0.95) * 1000, 2), + 'p99_ms': round(percentile(frames, 0.99) * 1000, 2), + 'slowest_quarter_fps': round(len(focus) / sum(focus), 1), + 'slowest_quarter_ms': round(statistics.mean(focus) * 1000, 2), + 'hitches': sum(1 for frametime in frames if frametime >= median * HITCH_FACTOR), + 'host': host_summary(), + } + + +def host_summary(): + """Short host name for the append CSV, so rows from several machines differ.""" + model = '' + try: + model = Path('/sys/devices/virtual/dmi/id/product_name').read_text().strip() + except OSError: + pass + cpu = '' + try: + for line in Path('/proc/cpuinfo').read_text().splitlines(): + if line.startswith('model name'): + cpu = line.split(':', 1)[1].strip() + break + except OSError: + pass + return ' / '.join(part for part in (model, cpu) if part) or 'unknown' + + +def collect(paths, limit=None): + results = [] + for path in paths[:limit] if limit else paths: + result = measure(path) + if result: + results.append(result) + return results + + +def render(results): + if not results: + return ('No netstats.csv files found. Play a match first, or pass ' + '--prefix/--csv.\nExpected under: /%s' % DEFAULT_GAME_LOGS) + lines = [ + '%-17s %6s %6s %6s %6s %6s %6s %5s' % ( + 'match', 'avgfps', 'p50ms', 'p95ms', 'p99ms', 'slowfps', 'slowms', 'hitch'), + ] + for result in results: + lines.append('%-17s %6.1f %6.2f %6.2f %6.2f %6.1f %6.2f %5d' % ( + result['match'], result['avg_fps'], result['p50_ms'], result['p95_ms'], + result['p99_ms'], result['slowest_quarter_fps'], + result['slowest_quarter_ms'], result['hitches'])) + lines.append('') + lines.append('avgfps over the match, p95/p99 frame time, slowfps/slowms over the' + ' slowest quarter,') + lines.append('hitch = samples at least %.0fx the median frame time.' % HITCH_FACTOR) + return '\n'.join(lines) + + +def append_csv(results, path): + path = Path(path).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + exists = path.is_file() and path.stat().st_size > 0 + with open(path, 'a', newline='') as handle: + writer = csv.DictWriter(handle, fieldnames=APPEND_HEADER, extrasaction='ignore') + if not exists: + writer.writeheader() + for result in results: + writer.writerow(result) + return path + + +def main(argv=None): + parser = argparse.ArgumentParser(description='Measure League frame pacing from netstats.csv logs.') + parser.add_argument('--prefix', type=Path, help='Darling prefix (default: DPREFIX or ~/.darling)') + parser.add_argument('--csv', type=Path, action='append', + help='measure this netstats.csv (repeatable); skips discovery') + parser.add_argument('--last', type=int, help='only the N newest matches') + parser.add_argument('--json', action='store_true', help='print JSON instead of a table') + parser.add_argument('--append', type=Path, help='append one CSV row per match to this file') + args = parser.parse_args(argv) + + if args.csv: + paths = args.csv + else: + prefix = args.prefix or default_prefix() + paths = discover(prefix) + results = collect(paths, args.last) + + if args.json: + print(json.dumps({'schema': 1, 'tool': 'darling-lol perf-baseline', + 'host': host_summary(), 'matches': results}, indent=2)) + else: + print(render(results)) + if args.append and results: + written = append_csv(results, args.append) + if not args.json: + print('Appended %d row(s) to %s' % (len(results), written)) + return 0 if results else 1 + + +if __name__ == '__main__': + sys.exit(main()) From 54f301a0e0d8f6788fc714389bea4cfd5e815c49 Mon Sep 17 00:00:00 2001 From: MintMachineHQ Date: Sun, 25 Oct 2026 12:28:48 -0600 Subject: [PATCH 2/3] fix(tools): address review feedback on diagnostics - doctor: resolve the workspace/Nix runtime before the native path, honour multi-entry XDG_DATA_DIRS, raise the kernel minimum to 5.14, and redact home/prefix/workspace paths by default (--no-redact opts out). Reports are written mode 0600. - crash-report: drop the whole-process table from the default bundle (--all-processes opts in), mask credentials in argv, strip window titles, validate --label, find the workspace launcher log, bound the log tail, describe missing logs, write the archive 0600 without owner metadata, and keep --json output pure JSON. - perf-baseline: reject non-finite times and absurd frame times, count --last by usable match, include the last sample interval in duration, record only the log basename, and rename p50/p95/p99 to sample_p* to make clear they are ten-second samples. - docs: correct the metric descriptions, flag names and shader-cache sign. --- docs/COMPATIBILITY.md | 21 ++-- tests/test_crash_report.py | 83 +++++++++++++- tests/test_doctor.py | 53 ++++++++- tests/test_perf_baseline.py | 34 +++++- tools/crash-report.py | 215 +++++++++++++++++++++++++++--------- tools/doctor.py | 80 ++++++++++++-- tools/perf-baseline.py | 46 +++++--- 7 files changed, 437 insertions(+), 95 deletions(-) diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index b2e1d52..be02f33 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -23,22 +23,23 @@ darling-lol --perf # frame pacing from the game's own logs darling-lol --perf --json # the same numbers as JSON ``` -`lol doctor` needs no runtime and starts nothing, so it is safe to run at any -time. `lol perf` reads the `netstats.csv` files that League already writes; it -does not touch the client or the game. +`darling-lol --doctor` needs no runtime and starts nothing, so it is safe to run +at any time. `darling-lol --perf` reads the `netstats.csv` files that League +already writes; it does not touch the client or the game. Open an issue titled `Compatibility: / ` and attach `doctor.json` plus the `perf --json` output. Do not paste a full `~/.darling` log or your home -directory; `lol report` produces a redacted bundle if more diagnostics are -needed. Then add a row here in a pull request, or mention the issue and a +directory; `darling-lol --report` produces a redacted bundle if more +diagnostics are needed. Then add a row here in a pull request, or mention the issue and a maintainer will add it. ### What the numbers mean -- `avgfps` is the mean of League's own ten-second `game.frametime_average` - samples over the whole match. -- `p50/p95/p99ms` are frame times: the typical frame, the slow 5 %, and the - slowest 1 %. +- `avgfps` is the inverse of the mean sampled frame time over the match, not + the average of per-second FPS values. +- `p50/p95/p99ms` are percentiles of League's ten-second `game.frametime_average` + samples: the typical sample, the slow 5 %, and the slowest 1 %. They describe + ten-second averages, not individual frames. - `slowfps` is the average FPS over the slowest quarter of the match, which is closer to what stutter feels like than the headline average. - `hitch` counts ten-second samples whose frame time is at least twice the @@ -61,7 +62,7 @@ Notes for the UHD 620 row: builds, so single-match comparisons are not meaningful. - Intel panel self-refresh (`i915.enable_psr`) was on for part of this period. See [panel self-refresh](TROUBLESHOOTING.md#blocky-video-or-flickering-startup-logo). -- Expect first-match shader compilation to show up as a low `p99` until the +- Expect first-match shader compilation to show up as a high `p99` until the cache is warm. The Ryzen 5 5500 / RX 6600 development machine is faster and is documented diff --git a/tests/test_crash_report.py b/tests/test_crash_report.py index bd1b64e..dea1b7e 100644 --- a/tests/test_crash_report.py +++ b/tests/test_crash_report.py @@ -51,12 +51,20 @@ class ProcessSnapshotTests(unittest.TestCase): relevant = crash_report.relevant_processes(rows) self.assertEqual([row['pid'] for row in relevant], [101, 102]) - def test_format_has_both_sections(self): + def test_format_defaults_to_the_runtime_and_client_tree(self): rows = crash_report.process_snapshot(self.proc) text = crash_report.format_processes(rows, crash_report.relevant_processes(rows)) - self.assertIn('# all processes owned by this user', text) + self.assertNotIn('# all processes owned by this user', text) self.assertIn('# runtime, client and zombie processes', text) self.assertIn('101\t100\tS\tmldr', text) + self.assertNotIn('100\t1\tS\tsystemd', text) + + def test_format_includes_everything_on_request(self): + rows = crash_report.process_snapshot(self.proc) + text = crash_report.format_processes(rows, crash_report.relevant_processes(rows), + all_processes=True) + self.assertIn('# all processes owned by this user', text) + self.assertIn('100\t1\tS\tsystemd', text) class HelperTests(unittest.TestCase): @@ -81,6 +89,48 @@ class HelperTests(unittest.TestCase): self.assertIsNone(text) self.assertIn('No such file', error) + def test_mask_argv_hides_credentials(self): + masked = crash_report.mask_argv([ + 'RiotClientServices', '--token', 'abc123', '--password=hunter2', + 'https://user:secret@example.test/path', '--region=EUW', + 'AUTH=topsecret', '--bearer', 'xyz', + ]) + self.assertNotIn('abc123', masked) + self.assertNotIn('hunter2', masked) + self.assertNotIn('secret', masked) + self.assertNotIn('topsecret', masked) + self.assertNotIn('xyz', masked) + self.assertIn('--region=EUW', masked) + + def test_scrub_uses_whole_tokens(self): + self.assertEqual( + crash_report.scrub('al and alps at /mnt/al on al-host', home=Path('/mnt/al'), + username='al', hostname='not-present'), + ' and alps at ~ on -host') + + def test_window_listing_drops_titles(self): + listing = crash_report.window_listing(json.dumps([ + {'address': '0x1', 'class': 'Riot Client', 'title': 'inbox - secret subject', + 'initialTitle': 'old secret', 'size': [100, 100]}, + ])) + self.assertIn('Riot Client', listing) + self.assertNotIn('secret', listing) + + def test_label_is_restricted(self): + with self.assertRaises(ValueError): + crash_report.bundle_name('../../escape') + with self.assertRaises(ValueError): + crash_report.bundle_name('hang report') + self.assertTrue(crash_report.bundle_name('hang-2').endswith('-hang-2.tar.gz')) + + def test_default_launch_log_prefers_workspace(self): + with tempfile.TemporaryDirectory() as folder: + workspace = Path(folder) + log = workspace / 'build/desktop-launch.log' + log.parent.mkdir() + log.write_text('launcher output\n') + self.assertEqual(crash_report.default_launch_log({'DARLING_WORKSPACE': folder}), log) + def test_human_size(self): self.assertEqual(crash_report.human_size(512), '512 B') self.assertEqual(crash_report.human_size(2048), '2 KiB') @@ -121,7 +171,8 @@ class BuildTests(unittest.TestCase): def test_bundle_contains_expected_files_and_summary(self): archive, manifest = self.build() self.assertTrue(archive.is_file()) - self.assertEqual(manifest['processes'], {'total': 2, 'relevant': 2, 'zombies': 1}) + self.assertEqual(manifest['processes'], {'total': 2, 'relevant': 2, 'zombies': 1, + 'included': 'runtime-client-zombies'}) self.assertEqual(manifest['redacted'], True) self.assertEqual(manifest['prefix'], '') with tarfile.open(archive) as tar: @@ -146,6 +197,28 @@ class BuildTests(unittest.TestCase): self.assertNotIn('do-not-publish', combined) self.assertIn('~/.darling running', combined) + def test_workspace_paths_are_redacted(self): + archive, _ = self.build(launch_log=None, + env=dict(self.env, DARLING_WORKSPACE=str(self.root / 'work'))) + with tarfile.open(archive) as tar: + text = tar.extractfile('lol-report/logs/launch.log.tail').read().decode() + self.assertNotIn(str(self.root), text) + self.assertIn('', text) + + def test_archive_is_private_and_has_no_owner_metadata(self): + archive, _ = self.build() + self.assertEqual(archive.stat().st_mode & 0o777, 0o600) + with tarfile.open(archive) as tar: + for member in tar.getmembers(): + self.assertEqual((member.uid, member.gid, member.uname, member.gname), (0, 0, '', '')) + + def test_all_processes_is_opt_in(self): + archive, manifest = self.build(all_processes=True) + self.assertEqual(manifest['processes']['included'], 'all') + with tarfile.open(archive) as tar: + text = tar.extractfile('lol-report/processes.txt').read().decode() + self.assertIn('# all processes owned by this user', text) + def test_no_redact_keeps_paths(self): archive, manifest = self.build(redact=False) self.assertEqual(manifest['redacted'], False) @@ -157,9 +230,11 @@ class BuildTests(unittest.TestCase): def test_missing_logs_are_explained(self): archive, manifest = self.build(launch_log=None) self.assertIn('logs/launch.log.tail', manifest['files']) + self.assertEqual(manifest['processes']['included'], 'runtime-client-zombies') with tarfile.open(archive) as tar: text = tar.extractfile('lol-report/logs/launch.log.tail').read().decode() - self.assertIn('no launcher log', text) + self.assertIn('desktop-launch.log', text) + self.assertIn('No such file', text) if __name__ == '__main__': diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 5261ee6..782fbba 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -42,6 +42,8 @@ def facts(**overrides): class CheckTests(unittest.TestCase): def test_kernel_rejects_old_release(self): self.assertEqual(doctor.check_kernel(facts(host={'kernel': '5.4.0'}))[0], 'warn') + self.assertEqual(doctor.check_kernel(facts(host={'kernel': '5.13.0'}))[0], 'warn') + self.assertEqual(doctor.check_kernel(facts(host={'kernel': '5.14.0'}))[0], 'ok') self.assertEqual(doctor.check_kernel(facts(host={'kernel': '6.8.0-arch1'}))[0], 'ok') self.assertEqual(doctor.check_kernel(facts(host={'kernel': 'unparsed'}))[0], 'warn') @@ -152,6 +154,29 @@ class PrefixStateTests(unittest.TestCase): self.assertEqual(doctor.prefix_state(self.prefix, self.proc)['stale_markers'], 2) +class RuntimeDirTests(unittest.TestCase): + def test_workspace_runtime_wins(self): + with tempfile.TemporaryDirectory() as folder: + runtime = Path(folder) / 'usr-local/libexec/darling' + (runtime / 'sbin').mkdir(parents=True) + (runtime / 'sbin/launchd').write_text('') + self.assertEqual(doctor.runtime_dir({'DARLING_WORKSPACE': folder}), runtime) + + def test_config_sdk_is_a_fallback(self): + with tempfile.TemporaryDirectory() as folder: + workspace = Path(folder) / 'workspace' + runtime = Path(folder) / 'sdk' + (runtime / 'sbin').mkdir(parents=True) + (runtime / 'sbin/launchd').write_text('') + (workspace / 'build').mkdir(parents=True) + (workspace / 'build/config.json').write_text(json.dumps({'sdk': str(runtime)})) + self.assertEqual(doctor.runtime_dir({'DARLING_WORKSPACE': str(workspace)}), runtime) + + def test_native_path_is_last(self): + self.assertEqual(doctor.runtime_dir({'DARLING_WORKSPACE': '/nonexistent'}), + Path('/usr/local/libexec/darling')) + + class GpuAndVulkanTests(unittest.TestCase): def test_read_gpus_follows_driver_link(self): with tempfile.TemporaryDirectory() as folder: @@ -173,8 +198,12 @@ class GpuAndVulkanTests(unittest.TestCase): icd = Path(folder) / 'vulkan/icd.d' icd.mkdir(parents=True) (icd / 'intel.json').write_text('{}') - found = doctor.vulkan_icds({'XDG_DATA_DIRS': folder}) + second = Path(folder) / 'other/vulkan/icd.d' + second.mkdir(parents=True) + (second / 'radeon.json').write_text('{}') + found = doctor.vulkan_icds({'XDG_DATA_DIRS': '%s:%s' % (folder, second.parents[1])}) self.assertIn(str(icd / 'intel.json'), found) + self.assertIn(str(second / 'radeon.json'), found) self.assertEqual(len(found), len(set(found))) @@ -197,6 +226,27 @@ class ReportTests(unittest.TestCase): self.assertEqual(document['environment'], {'DISPLAY': ':0', 'RCLOL_REGION': 'EUW'}) self.assertNotIn('MANGOHUD', document['environment']) + def test_report_redacts_paths_by_default(self): + home = str(Path.home()) + data = facts(prefix=home + '/.darling', workspace=home + '/work', + environment={'DPREFIX': home + '/.darling', + 'DARLING_WORKSPACE': home + '/work'}, + prefix_state={'pid': 0, 'state': 'absent', 'stale_markers': 0}) + results = [{'id': 'prefix', 'title': 'Darling prefix', 'status': 'ok', + 'message': home + '/.darling'}] + document = doctor.report(data, results) + self.assertEqual(document['prefix'], '') + self.assertEqual(document['environment']['DPREFIX'], '') + self.assertEqual(document['environment']['DARLING_WORKSPACE'], '') + self.assertEqual(document['checks'][0]['message'], '') + self.assertNotIn(home, json.dumps(document)) + + def test_report_can_keep_real_paths(self): + data = facts(prefix='/tmp/darling-prefix') + document = doctor.report(data, [], redact=False) + self.assertEqual(document['prefix'], '/tmp/darling-prefix') + self.assertFalse(document['redacted']) + def test_cli_writes_json(self): with tempfile.TemporaryDirectory() as folder: prefix = Path(folder) / 'prefix' @@ -209,6 +259,7 @@ class ReportTests(unittest.TestCase): document = json.loads(out.read_text()) self.assertEqual(document['tool'], 'darling-lol doctor') self.assertIn('checks', document) + self.assertEqual(out.stat().st_mode & 0o777, 0o600) if __name__ == '__main__': diff --git a/tests/test_perf_baseline.py b/tests/test_perf_baseline.py index 13116fa..ba9bc27 100644 --- a/tests/test_perf_baseline.py +++ b/tests/test_perf_baseline.py @@ -43,14 +43,29 @@ class MeasureTests(unittest.TestCase): result = perf.measure(path) self.assertEqual(result['match'], '2026-10-23 17:03') self.assertEqual(result['samples'], 8) - self.assertEqual(result['seconds'], 70.0) + self.assertEqual(result['seconds'], 80.0) self.assertEqual(result['avg_fps'], 24.2) - self.assertEqual(result['p50_ms'], 30.0) - self.assertEqual(result['p95_ms'], 90.0) + self.assertEqual(result['sample_p50_ms'], 30.0) + self.assertEqual(result['sample_p95_ms'], 90.0) self.assertEqual(result['slowest_quarter_fps'], 13.3) self.assertEqual(result['slowest_quarter_ms'], 75.0) self.assertEqual(result['hitches'], 2) + def test_file_is_a_basename_and_not_a_path(self): + path = write_match(self.root, [0.03]) + self.assertEqual(perf.measure(path)['file'], path.name) + self.assertNotIn(str(self.root), json.dumps(perf.measure(path))) + + def test_non_finite_time_and_huge_frames_are_skipped(self): + path = self.root / '2026-01-01T00-00-00' / '2026-01-01T00-00-00_netstats.csv' + path.parent.mkdir() + path.write_text(','.join(HEADER) + '\n' + '0.03,0,nan,0,0\n' + '0.03,0,inf,0,0\n' + '1e308,0,10,0,0\n' + '0.03,0,10,0,0\n') + self.assertEqual(perf.measure(path)['samples'], 1) + def test_bad_and_empty_rows_are_skipped(self): path = self.root / '2026-01-01T00-00-00' / '2026-01-01T00-00-00_netstats.csv' path.parent.mkdir() @@ -130,6 +145,8 @@ class OutputTests(unittest.TestCase): self.assertEqual(len(rows), 2) self.assertEqual(rows[0]['match'], '2026-10-23 17:03') self.assertEqual(rows[0]['avg_fps'], '20.0') + self.assertEqual(rows[0]['sample_p50_ms'], '40.0') + self.assertNotIn('p50_ms', rows[0]) self.assertEqual(path.read_text().count('measured,match'), 1) def test_json_cli(self): @@ -140,6 +157,17 @@ class OutputTests(unittest.TestCase): document = json.loads(stream.getvalue()) self.assertEqual(document['matches'][0]['samples'], 4) + def test_last_counts_usable_matches(self): + empty = self.root / '2026-10-24T10-00-00' / '2026-10-24T10-00-00_netstats.csv' + empty.parent.mkdir(parents=True) + empty.write_text(','.join(HEADER) + '\n') + results = perf.collect([empty, self.path], 1) + self.assertEqual([result['match'] for result in results], ['2026-10-23 17:03']) + + def test_duration_includes_the_final_sample_interval(self): + path = write_match(self.root, [0.04] * 4) + self.assertEqual(perf.measure(path)['seconds'], 40.0) + def test_cli_without_matches_returns_one(self): missing = self.root / 'missing.csv' with contextlib.redirect_stdout(io.StringIO()): diff --git a/tools/crash-report.py b/tools/crash-report.py index 440e3f6..7d1565d 100644 --- a/tools/crash-report.py +++ b/tools/crash-report.py @@ -7,7 +7,8 @@ launcher logs, recent kernel messages and the last core dumps, and writes one gzipped tarball that can be attached to an issue. Home directory, user name and host name are replaced by placeholders by -default; environment variables are an allowlist, so tokens are never included. +default. Environment variables are not collected, and credentials in command +lines are masked. Usage: python3 tools/crash-report.py @@ -15,6 +16,7 @@ Usage: python3 tools/crash-report.py --no-redact --json """ import argparse +from collections import deque import json import os import platform @@ -42,15 +44,52 @@ PROCESS_NAMES = ( 'LeagueClient', 'League of Legends', 'CrBrowserMain', 'CrGpuMain', 'CrRendererMain', 'CrUtilityMain', 'wine', 'gamescope', 'Xwayland', ) +SENSITIVE = re.compile( + r'(?i)^(?:--?)?(?:pass(?:word|wd)?|token|secret|api[-_]?key|auth(?:orization)?|' + r'credential|cookie|session|bearer)$') +SENSITIVE_ASSIGNMENT = re.compile( + r'(?i)^((?:--?)?(?:pass(?:word|wd)?|token|secret|api[-_]?key|auth(?:orization)?|' + r'credential|cookie|session)[^=]*)=(.*)$') +URL_USERINFO = re.compile(r'(://[^/:@\s]+:)[^/@\s]+@') def read_tail(path, limit=LOG_TAIL_LINES): + """Return the last lines without holding the whole log in memory.""" try: with open(path, 'r', errors='replace') as handle: - lines = handle.readlines() + lines = deque(handle, maxlen=limit) except OSError as error: return None, str(error) - return ''.join(lines[-limit:]), None + return ''.join(lines), None + + +def mask_argv(arguments): + """Replace credentials in a command line, without hiding the command itself.""" + masked = [] + hide_next = False + for argument in arguments: + if hide_next: + masked.append('') + hide_next = False + continue + if SENSITIVE.match(argument): + masked.append(argument) + hide_next = True + continue + assignment = SENSITIVE_ASSIGNMENT.match(argument) + if assignment: + masked.append(assignment.group(1) + '=') + continue + masked.append(URL_USERINFO.sub(r'\1@', argument)) + return ' '.join(masked) + + +def command_arguments(proc): + try: + raw = (proc / 'cmdline').read_bytes() + except OSError: + return [] + return [part.decode(errors='replace') for part in raw.split(b'\0') if part] def process_snapshot(proc_root=Path('/proc')): @@ -70,7 +109,7 @@ def process_snapshot(proc_root=Path('/proc')): if owner != uid and state != 'Z': continue comm = (entry / 'comm').read_text().strip() - argv = (entry / 'cmdline').read_bytes().replace(b'\0', b' ').decode(errors='replace').strip() + argv = mask_argv(command_arguments(entry)).strip() except (OSError, ValueError, IndexError): continue rows.append({ @@ -94,13 +133,16 @@ def relevant_processes(rows): return selected -def format_processes(rows, relevant): - lines = ['# all processes owned by this user, plus zombies', - 'pid\tppid\tstate\tcomm\targv'] - for row in rows: - lines.append('%s\t%s\t%s\t%s\t%s' % (row['pid'], row['ppid'], row['state'], row['comm'], row['argv'])) - lines.append('') +def format_processes(rows, relevant, all_processes=False): + lines = [] + if all_processes: + lines.append('# all processes owned by this user, plus zombies') + lines.append('pid\tppid\tstate\tcomm\targv') + for row in rows: + lines.append('%s\t%s\t%s\t%s\t%s' % (row['pid'], row['ppid'], row['state'], row['comm'], row['argv'])) + lines.append('') lines.append('# runtime, client and zombie processes') + lines.append('pid\tppid\tstate\tcomm\targv') for row in relevant: lines.append('%s\t%s\t%s\t%s\t%s' % (row['pid'], row['ppid'], row['state'], row['comm'], row['argv'])) return '\n'.join(lines) + '\n' @@ -135,12 +177,33 @@ def coredumps(which): return output or '# no core dumps\n' +CLIENT_FIELDS = ('address', 'mapped', 'hidden', 'at', 'size', 'workspace', + 'floating', 'fullscreen', 'class', 'initialClass', 'pinned', + 'grouped', 'swallowing', 'monitor') + + +def window_listing(output): + """Keep geometry and class from hyprctl; drop titles, which are private.""" + try: + clients = json.loads(output) + except ValueError: + return '# hyprctl clients output could not be parsed\n' + if not isinstance(clients, list): + return '# unexpected hyprctl clients output\n' + visible = [ + {key: client[key] for key in CLIENT_FIELDS if key in client} + for client in clients if isinstance(client, dict) + ] + return json.dumps(visible, indent=2) + '\n' + + def display_state(which, env): if env.get('WAYLAND_DISPLAY') and which('hyprctl'): output, error = run_command(['hyprctl', 'monitors', '-j']) clients, client_error = run_command(['hyprctl', 'clients', '-j']) - return '# hyprctl monitors\n%s\n# hyprctl clients\n%s\n%s' % ( - output or error, clients or client_error, '') + listing = window_listing(clients) if clients else '# unavailable: %s\n' % client_error + return '# hyprctl monitors\n%s\n# hyprctl clients (titles omitted)\n%s' % ( + output or error, listing) if which('xrandr') and env.get('DISPLAY'): output, error = run_command(['xrandr', '--query']) if output: @@ -149,34 +212,57 @@ def display_state(which, env): env.get('DISPLAY'), env.get('WAYLAND_DISPLAY')) +def replace_token(text, value, placeholder): + """Replace a whole word, so a short user name cannot hit unrelated text.""" + return re.sub(r'(?'), - (hostname or socket.gethostname(), ''), - ] - for value, placeholder in replacements: - if value: - text = text.replace(value, placeholder) + text = text.replace(str(home or Path.home()), '~') + username = username or os.environ.get('USER') or '' + if username: + text = replace_token(text, username, '') + hostname = hostname or socket.gethostname() + if hostname: + text = replace_token(text, hostname, '') return text +LABEL = re.compile(r'^[A-Za-z0-9._-]{1,64}$') + + def bundle_name(label=None): + if label is not None and (not LABEL.match(label) or label in ('.', '..')): + raise ValueError('label may contain only letters, digits, dot, underscore and dash') stamp = time.strftime('%Y%m%d-%H%M%S', time.localtime()) return 'lol-report-%s%s.tar.gz' % (stamp, '-' + label if label else '') -def build(prefix, out_dir, launch_log=None, label=None, redact=True, +def default_launch_log(env): + """Launcher log written by desktop-run.sh, preferring the active workspace.""" + candidates = [] + workspace = env.get('DARLING_WORKSPACE') + if workspace: + candidates.append(Path(workspace) / 'build/desktop-launch.log') + root = Path(__file__).resolve().parents[1] + candidates.append(root / 'build/desktop-launch.log') + candidates.append(root / 'build/lol-launch.log') + return next((path for path in candidates if path.is_file()), candidates[0]) + + +def build(prefix, out_dir, launch_log=None, label=None, redact=True, all_processes=False, proc_root=Path('/proc'), which=None, env=None): """Write the bundle and return its path and manifest.""" env = dict(os.environ if env is None else env) which = which or shutil.which prefix = Path(prefix or env.get('DPREFIX') or (Path.home() / '.darling')).expanduser() out_dir = Path(out_dir).expanduser() - out_dir.mkdir(parents=True, exist_ok=True) + out_dir.mkdir(mode=0o700, parents=True, exist_ok=True) archive = out_dir / bundle_name(label) + launch_log = Path(launch_log).expanduser() if launch_log else default_launch_log(env) facts = doctor.gather(prefix=prefix, env=env, proc_root=proc_root, which=which) checks = doctor.run_checks(facts) @@ -185,21 +271,27 @@ def build(prefix, out_dir, launch_log=None, label=None, redact=True, dserver_log = Path(prefix) / 'private/var/log/dserver.log' log_text, log_error = read_tail(dserver_log) - launch_text, launch_error = (read_tail(launch_log) if launch_log else (None, 'no launcher log')) + launch_text, launch_error = read_tail(launch_log) entries = { - 'processes.txt': format_processes(rows, relevant), - 'logs/dserver.log.tail': log_text or '# unavailable: %s\n' % log_error, - 'logs/launch.log.tail': launch_text or '# unavailable: %s\n' % launch_error, + 'processes.txt': format_processes(rows, relevant, all_processes), + 'logs/dserver.log.tail': (log_text if log_text is not None + else '# unavailable: %s\n' % log_error), + 'logs/launch.log.tail': (launch_text if launch_text is not None + else '# unavailable: %s\n' % launch_error), 'logs/kernel.txt': kernel_messages(which), 'display.txt': display_state(which, env), 'coredumps.txt': coredumps(which), } if redact: - entries = {name: scrub(text, username=env.get('USER')) for name, text in entries.items()} + workspace = facts.get('workspace', '') + + def private(text): + return scrub(doctor.redact_text(text, prefix, workspace), username=env.get('USER')) + + entries = {name: private(text) for name, text in entries.items()} checks = [ - dict(check, title=scrub(check['title'], username=env.get('USER')), - message=scrub(check['message'], username=env.get('USER'))) + dict(check, title=private(check['title']), message=private(check['message'])) for check in checks ] @@ -210,8 +302,14 @@ def build(prefix, out_dir, launch_log=None, label=None, redact=True, 'python': platform.python_version(), 'prefix': '' if redact else facts['prefix'], 'redacted': redact, + 'archive': archive.name, 'doctor': {'summary': doctor.summarize(checks), 'checks': checks}, - 'processes': {'total': len(rows), 'relevant': len(relevant), 'zombies': sum(1 for row in rows if row['state'] == 'Z')}, + 'processes': { + 'total': len(rows), + 'relevant': len(relevant), + 'zombies': sum(1 for row in rows if row['state'] == 'Z'), + 'included': 'all' if all_processes else 'runtime-client-zombies', + }, 'files': sorted(entries), } if redact: @@ -227,11 +325,24 @@ def build(prefix, out_dir, launch_log=None, label=None, redact=True, with tarfile.open(archive, 'w:gz') as tar: for path in sorted(root.rglob('*')): if path.is_file(): - tar.add(path, arcname=str(Path('lol-report') / path.relative_to(root))) + add_private(tar, path, str(Path('lol-report') / path.relative_to(root))) + os.chmod(archive, 0o600) return archive, manifest +def add_private(tar, path, name): + """Add a file without this machine's uid, user name or timestamps.""" + info = tarfile.TarInfo(name) + info.size = path.stat().st_size + info.mode = 0o600 + info.mtime = 0 + info.uid = info.gid = 0 + info.uname = info.gname = '' + with open(path, 'rb') as handle: + tar.addfile(info, handle) + + def readme(manifest): return '''darling-lol crash report ======================== @@ -243,17 +354,18 @@ Redacted: %(redacted)s What is inside: - report.json: the doctor checks, host summary and process counts -- processes.txt: this user's processes plus every zombie, and the subset that is - part of the Darling runtime or the Riot/League client +- processes.txt: the Darling runtime, Riot/League client and zombie processes; + pass --all-processes to include every process owned by this user - logs/dserver.log.tail: the last %(lines)d lines of the runtime log - logs/launch.log.tail: the last lines the launcher wrote - logs/kernel.txt: graphics-related kernel messages - display.txt: monitors and windows (or an explanation when unavailable) - coredumps.txt: recent systemd-coredump entries, if any -Tokens, passwords and other environment secrets are not collected. Home -directory, user name and host name are replaced with placeholders unless the -bundle was created with --no-redact. +Environment variables are not collected. Credentials in command-line arguments +(--token, --password, URL userinfo) are masked. Home directory, user name and +host name are replaced with placeholders unless the bundle was created with +--no-redact. The archive is mode 0600 and carries no owner metadata. ''' % { 'created': manifest['created'], 'distro': manifest['host']['distro'], @@ -272,30 +384,33 @@ def main(argv=None): default=Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'darling-lol/reports', help='directory for the tarball (default: ~/.cache/darling-lol/reports)') parser.add_argument('--launch-log', type=Path, help='launcher log to include') - parser.add_argument('--label', help='short label added to the file name') + parser.add_argument('--label', help='short label added to the file name ([A-Za-z0-9._-])') + parser.add_argument('--all-processes', action='store_true', + help='include every process owned by this user, not only the runtime/client tree') parser.add_argument('--no-redact', action='store_true', help='keep the real home path, user and host name') parser.add_argument('--json', action='store_true', help='print the manifest') args = parser.parse_args(argv) - launch_log = args.launch_log - if launch_log is None: - candidate = Path(__file__).resolve().parents[1] / 'build/lol-launch.log' - launch_log = candidate if candidate.is_file() else None - - archive, manifest = build( - prefix=args.prefix, - out_dir=args.out, - launch_log=launch_log, - label=args.label, - redact=not args.no_redact, - ) + try: + archive, manifest = build( + prefix=args.prefix, + out_dir=args.out, + launch_log=args.launch_log, + label=args.label, + redact=not args.no_redact, + all_processes=args.all_processes, + ) + except ValueError as error: + parser.error(str(error)) if args.json: print(json.dumps(manifest, indent=2)) + print('Report: %s (%s)' % (archive, human_size(archive.stat().st_size)), file=sys.stderr) + return 0 print('Report: %s (%s)' % (archive, human_size(archive.stat().st_size))) summary = manifest['doctor']['summary'] print('%d ok, %d warning(s), %d failure(s); %d zombie process(es)' % ( summary['ok'], summary['warn'], summary['fail'], manifest['processes']['zombies'])) - print('Attach this file to the issue; it contains no tokens or environment secrets.') + print('Attach this file to the issue; environment variables and command-line credentials are omitted.') return 0 diff --git a/tools/doctor.py b/tools/doctor.py index 7979f56..9ba5558 100644 --- a/tools/doctor.py +++ b/tools/doctor.py @@ -30,7 +30,7 @@ MIN_FREE_DISK_GIB = 5 WARN_FREE_DISK_GIB = 15 MIN_RAM_GIB = 4 WARN_RAM_GIB = 8 -MIN_KERNEL = (5, 10) +MIN_KERNEL = (5, 14) # A report is public when it is attached to an issue, so only the variables # that describe the runtime environment are collected. ENV_ALLOWLIST = ( @@ -128,7 +128,8 @@ def vulkan_icds(env=None): '/usr/local/share/vulkan/icd.d', '/run/opengl-driver/share/vulkan/icd.d', '/etc/vulkan/icd.d', - os.path.join(env.get('XDG_DATA_DIRS', ''), 'vulkan/icd.d') if env.get('XDG_DATA_DIRS') else None, + *[os.path.join(value, 'vulkan/icd.d') + for value in (env.get('XDG_DATA_DIRS') or '').split(':') if value], ] found = [] for folder in filter(None, search): @@ -165,6 +166,27 @@ def prefix_state(prefix, proc_root=Path('/proc')): return {'pid': pid, 'state': state, 'stale_markers': stale_markers} +def runtime_dir(env=None): + """Installed Darling runtime: workspace/Nix first, then the native path.""" + env = os.environ if env is None else env + candidates = [] + workspace = env.get('DARLING_WORKSPACE') + if workspace: + candidates.append(Path(workspace) / 'usr-local/libexec/darling') + config = Path(workspace) / 'build/config.json' + try: + configured = json.loads(config.read_text()).get('sdk') + except (OSError, ValueError, AttributeError): + configured = None + if configured: + candidates.append(Path(configured)) + candidates.append(Path('/usr/local/libexec/darling')) + for candidate in candidates: + if (candidate / 'sbin/launchd').is_file(): + return candidate + return next((candidate for candidate in candidates if candidate.is_dir()), candidates[0]) + + def gather(prefix=None, env=None, proc_root=Path('/proc'), sys_root=Path('/sys'), which=None): """Collect facts once; checks below only interpret them and stay testable.""" env = dict(os.environ if env is None else env) @@ -181,7 +203,7 @@ def gather(prefix=None, env=None, proc_root=Path('/proc'), sys_root=Path('/sys') free_disk = 0 socket = prefix / '.darlingserver.sock' - runtime_bin = Path('/usr/local/libexec/darling') + runtime_bin = runtime_dir(env) return { 'host': { 'distro': release.get('PRETTY_NAME') or platform.system(), @@ -205,6 +227,8 @@ def gather(prefix=None, env=None, proc_root=Path('/proc'), sys_root=Path('/sys') 'gpus': read_gpus(sys_root), 'vulkan_icds': vulkan_icds(env), 'prefix': str(prefix), + 'workspace': env.get('DARLING_WORKSPACE', ''), + 'runtime_dir': str(runtime_bin), 'prefix_exists': prefix.is_dir(), 'prefix_launchd': (runtime_bin / 'sbin/launchd').is_file(), 'prefix_socket': socket.exists(), @@ -310,7 +334,8 @@ def check_prefix(facts): if not facts['prefix_exists']: return 'fail', 'Prefix %s does not exist; run the installer first' % facts['prefix'] if not facts['prefix_launchd']: - return 'fail', 'Installed launchd is missing; the runtime install did not complete' + return 'fail', ('Installed launchd is missing from %s; the runtime install did not complete' + % facts.get('runtime_dir', '/usr/local/libexec/darling')) return 'ok', facts['prefix'] @@ -364,21 +389,51 @@ def summarize(results): return counts -def report(facts, results): +def redact_text(text, prefix='', workspace=''): + """Replace the home, prefix and workspace paths before a report is shared.""" + replacements = [ + (str(prefix), ''), + (str(workspace), ''), + (str(Path.home()), '~'), + ] + for value, placeholder in replacements: + if value and value not in ('', '.'): + text = text.replace(value, placeholder) + return text + + +def report(facts, results, redact=True): + prefix = facts['prefix'] + workspace = facts.get('workspace', '') + checks = results + environment = facts['environment'] + icds = facts['vulkan_icds'] + if redact: + checks = [ + dict(check, title=redact_text(check['title'], prefix, workspace), + message=redact_text(check['message'], prefix, workspace)) + for check in results + ] + environment = { + key: redact_text(value, prefix, workspace) + for key, value in environment.items() + } + icds = [redact_text(item, prefix, workspace) for item in icds] return { 'schema': SCHEMA, 'tool': 'darling-lol doctor', 'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), - 'summary': summarize(results), + 'summary': summarize(checks), 'host': facts['host'], 'session': facts['session'], 'memory': facts['memory'], 'disk': facts['disk'], 'gpus': facts['gpus'], - 'vulkan_icds': facts['vulkan_icds'], - 'prefix': facts['prefix'], - 'environment': facts['environment'], - 'checks': results, + 'vulkan_icds': icds, + 'prefix': '' if redact else prefix, + 'redacted': redact, + 'environment': environment, + 'checks': checks, } @@ -410,14 +465,17 @@ def main(argv=None): parser.add_argument('--json', action='store_true', help='print the report as JSON') parser.add_argument('--report', type=Path, help='write the JSON report to this file') parser.add_argument('--prefix', type=Path, help='Darling prefix (default: DPREFIX or ~/.darling)') + parser.add_argument('--no-redact', action='store_true', + help='keep the real prefix and workspace paths in the report') args = parser.parse_args(argv) facts = gather(prefix=args.prefix) results = run_checks(facts) - document = report(facts, results) + document = report(facts, results, redact=not args.no_redact) if args.report: args.report.write_text(json.dumps(document, indent=2) + '\n') + os.chmod(args.report, 0o600) if args.json: print(json.dumps(document, indent=2)) else: diff --git a/tools/perf-baseline.py b/tools/perf-baseline.py index 528ffb5..168ea7c 100644 --- a/tools/perf-baseline.py +++ b/tools/perf-baseline.py @@ -38,10 +38,15 @@ TIME_COLUMN = 'game.time' DEFAULT_GAME_LOGS = Path( 'Applications/League of Legends.app/Contents/LoL/Logs/GameLogs') HITCH_FACTOR = 2.0 +# A ten-second sample above a minute is corrupt, and would make derived +# milliseconds non-finite in the JSON report. +MAX_FRAMETIME_SECONDS = 60.0 +MAX_GAME_SECONDS = 6 * 60 * 60 +SAMPLE_SECONDS = 10.0 APPEND_HEADER = ( - 'measured', 'match', 'samples', 'seconds', 'avg_fps', 'mean_ms', - 'p50_ms', 'p95_ms', 'p99_ms', 'slowest_quarter_fps', 'slowest_quarter_ms', - 'hitches', 'host', + 'measured', 'match', 'file', 'samples', 'seconds', 'avg_fps', 'mean_ms', + 'sample_p50_ms', 'sample_p95_ms', 'sample_p99_ms', + 'slowest_quarter_fps', 'slowest_quarter_ms', 'hitches', 'host', ) @@ -69,7 +74,8 @@ def read_rows(path): game_time = float(row.get(TIME_COLUMN) or 0) except (TypeError, ValueError): continue - if math.isfinite(frametime) and frametime > 0: + if (math.isfinite(frametime) and 0 < frametime <= MAX_FRAMETIME_SECONDS + and math.isfinite(game_time) and 0 <= game_time <= MAX_GAME_SECONDS): yield game_time, frametime @@ -97,20 +103,22 @@ def measure(path, measured=None): return None times = [time_value for time_value, _ in samples] frames = [frametime for _, frametime in samples] - seconds = max(times) - min(times) if len(times) > 1 else 10.0 * len(samples) + intervals = [later - earlier for earlier, later in zip(times, times[1:]) if later > earlier] + interval = statistics.median(intervals) if intervals else SAMPLE_SECONDS + seconds = max(times) - min(times) + interval if len(times) > 1 else interval focus = sorted(frames)[-max(1, math.ceil(len(frames) / 4)):] median = statistics.median(frames) return { 'measured': measured or time.strftime('%Y-%m-%dT%H:%M:%S'), 'match': match_label(path), - 'path': str(path), + 'file': Path(path).name, 'samples': len(frames), 'seconds': round(seconds, 1), 'avg_fps': round(len(frames) / sum(frames), 1), 'mean_ms': round(statistics.mean(frames) * 1000, 2), - 'p50_ms': round(percentile(frames, 0.50) * 1000, 2), - 'p95_ms': round(percentile(frames, 0.95) * 1000, 2), - 'p99_ms': round(percentile(frames, 0.99) * 1000, 2), + 'sample_p50_ms': round(percentile(frames, 0.50) * 1000, 2), + 'sample_p95_ms': round(percentile(frames, 0.95) * 1000, 2), + 'sample_p99_ms': round(percentile(frames, 0.99) * 1000, 2), 'slowest_quarter_fps': round(len(focus) / sum(focus), 1), 'slowest_quarter_ms': round(statistics.mean(focus) * 1000, 2), 'hitches': sum(1 for frametime in frames if frametime >= median * HITCH_FACTOR), @@ -137,11 +145,14 @@ def host_summary(): def collect(paths, limit=None): + """Measure usable matches; --last counts matches, not file paths.""" results = [] - for path in paths[:limit] if limit else paths: + for path in paths: result = measure(path) if result: results.append(result) + if limit and len(results) >= limit: + break return results @@ -155,13 +166,16 @@ def render(results): ] for result in results: lines.append('%-17s %6.1f %6.2f %6.2f %6.2f %6.1f %6.2f %5d' % ( - result['match'], result['avg_fps'], result['p50_ms'], result['p95_ms'], - result['p99_ms'], result['slowest_quarter_fps'], - result['slowest_quarter_ms'], result['hitches'])) + result['match'], result['avg_fps'], result['sample_p50_ms'], + result['sample_p95_ms'], result['sample_p99_ms'], + result['slowest_quarter_fps'], result['slowest_quarter_ms'], + result['hitches'])) lines.append('') - lines.append('avgfps over the match, p95/p99 frame time, slowfps/slowms over the' - ' slowest quarter,') - lines.append('hitch = samples at least %.0fx the median frame time.' % HITCH_FACTOR) + lines.append('avgfps = 1 / mean sampled frame time. p50/p95/p99 are percentiles of') + lines.append('the ten-second samples, not of individual frames. slowfps/slowms cover') + lines.append('the slowest quarter of the match. hitch = samples at least %.0fx the' + % HITCH_FACTOR) + lines.append('median frame time.') return '\n'.join(lines) From a07ebb8c5aff15222b7538330fe39c5e1c0e3adc Mon Sep 17 00:00:00 2001 From: MintMachineHQ Date: Sun, 25 Oct 2026 12:34:25 -0600 Subject: [PATCH 3/3] docs: be precise about what the crash bundle copies --- docs/TROUBLESHOOTING.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 1f017de..ba41448 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -19,12 +19,14 @@ darling-lol --report # ~/.cache/darling-lol/reports/*.tar.gz darling-lol --perf # frame pacing from the game's own logs ``` -The crash bundle contains the doctor checks, the process table, the runtime and -launcher log tails, graphics kernel messages, display state and recent core -dumps. It does not collect environment variables, tokens or passwords, and it -replaces your home directory, user name and host name with placeholders unless -you pass `--no-redact`. See [compatibility](COMPATIBILITY.md) for what the -performance numbers mean and for verified machines. +The crash bundle contains the doctor checks, the runtime/client/zombie process +table, the runtime and launcher log tails, graphics kernel messages, display +state and recent core dumps. It does not collect environment variables, masks +credential-shaped command-line values, and replaces your home directory, user +name and host name unless you pass `--no-redact`. Log text itself is copied +verbatim, so skim the bundle before sharing it. See +[compatibility](COMPATIBILITY.md) for what the performance numbers mean and +for verified machines. ## NixOS launcher fails before Riot opens