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.
This commit is contained in:
MintMachineHQ committed 2026-10-25 12:28:48 -06:00
1 parent 11f7252004
commit 54f301a0e0
7 files changed
+437 -95

No files matched your search

+11 -10
View File
@@ -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: <distro> / <GPU>` 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
+79 -4
View File
@@ -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:[email protected]/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'),
'<user> and alps at ~ on <user>-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'], '<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('<workspace>', 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__':
+52 -1
View File
@@ -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'], '<prefix>')
self.assertEqual(document['environment']['DPREFIX'], '<prefix>')
self.assertEqual(document['environment']['DARLING_WORKSPACE'], '<workspace>')
self.assertEqual(document['checks'][0]['message'], '<prefix>')
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__':
+31 -3
View File
@@ -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()):
+165 -50
View File
@@ -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('<redacted>')
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) + '=<redacted>')
continue
masked.append(URL_USERINFO.sub(r'\1<redacted>@', 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'(?<![A-Za-z0-9_])%s(?![A-Za-z0-9_])' % re.escape(value),
placeholder, text)
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 '', '<user>'),
(hostname or socket.gethostname(), '<host>'),
]
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, '<user>')
hostname = hostname or socket.gethostname()
if hostname:
text = replace_token(text, hostname, '<host>')
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': '<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
+69 -11
View File
@@ -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), '<prefix>'),
(str(workspace), '<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': '<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:
+30 -16
View File
@@ -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)