mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
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:
1 parent
11f7252004
commit
54f301a0e0
7 files changed
+437
-95
No files matched your search
+11
-10
@@ -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
|
||||
|
||||
@@ -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
@@ -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__':
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user