feat(m15): collect session-scoped system logs (kernel + coredumps) — 0.31.0

core/syslogs.py gathers, scoped to the diagnostic window:
- kernel-log slice (journalctl -k): Xid, OOM, MCE, PCIe AER, thermal, hung tasks
- crashed-process records (coredumpctl): exe, signal, when
Stored as syslogs.txt in the diagnostic dir, included in the Report bundle, and
fed to the AI on "Explain" alongside the game logs. Best-effort (degrades if the
tools are missing/denied); treats journalctl's "-- No entries --" as empty.
Tests + docs (M15/SPEC).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:10:30 +02:00
parent bffaf73ad4
commit 984292c368
9 changed files with 156 additions and 9 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.30.0"
__version__ = "0.31.0"
+8 -1
View File
@@ -51,7 +51,7 @@ def store(result, capture_path=None, since: float | None = None) -> Path | None:
if not enabled():
return None
from ..render import render_summary
from . import ai, gamelogs
from . import ai, gamelogs, syslogs
target = _new_dir(getattr(result, "game", None))
@@ -80,6 +80,13 @@ def store(result, capture_path=None, since: float | None = None) -> Path | None:
_write(target / "gamelogs.txt", logs)
except OSError:
pass
try:
sys_logs = syslogs.collect(since=since)
if sys_logs:
_write(target / "syslogs.txt", sys_logs)
except OSError:
pass
return target
+70
View File
@@ -0,0 +1,70 @@
"""Session-scoped system logs for diagnostics (M15): kernel log + crashed-process records.
Reads the kernel ring buffer slice (`journalctl -k`) and systemd-coredump records
(`coredumpctl`) covering the diagnostic window, so the report bundle and the AI both see what
the *system* logged when something went wrong — Xid, OOM-killer, MCE, PCIe AER, thermal, hung
tasks, and whether a process (the game/wine) actually dumped core (SIGSEGV/ABRT). Best-effort
and size-bounded: degrades silently if the tools are missing or access is denied. Stdlib only.
"""
from __future__ import annotations
import shutil
import subprocess
import time
_MAX = 8000 # cap each section so the prompt/report stays small
def _since_arg(since: float | None) -> str | None:
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(since)) if since else None
def _run(cmd: list[str], timeout: float = 15.0) -> str:
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.SubprocessError):
return ""
return (proc.stdout or "").strip()
def kernel_log(since: float | None = None, max_bytes: int = _MAX) -> str:
if not shutil.which("journalctl"):
return ""
cmd = ["journalctl", "-k", "--no-pager"]
since_arg = _since_arg(since)
if since_arg:
cmd += ["--since", since_arg]
out = _run(cmd)
if not out or out.strip().lower() == "-- no entries --": # journalctl's empty marker
return ""
return out[-max_bytes:]
def coredumps(since: float | None = None, max_bytes: int = _MAX) -> str:
if not shutil.which("coredumpctl"):
return ""
cmd = ["coredumpctl", "list", "--no-pager"]
since_arg = _since_arg(since)
if since_arg:
cmd += ["--since", since_arg]
out = _run(cmd)
if not out or "no coredumps" in out.lower():
return ""
return out[-max_bytes:]
def available() -> bool:
return bool(shutil.which("journalctl") or shutil.which("coredumpctl"))
def collect(since: float | None = None) -> str:
"""Kernel-log slice + crashed-process records as one labelled block ('' if none)."""
sections: list[str] = []
kern = kernel_log(since)
if kern:
sections.append(f"--- Kernel log (journalctl -k) ---\n{kern}")
cores = coredumps(since)
if cores:
sections.append(f"--- Crashed processes (coredumpctl) ---\n{cores}")
return "\n\n".join(sections)
+4 -1
View File
@@ -115,7 +115,7 @@ class DiagnosticDialog(QDialog):
threading.Thread(target=self._work_explain, daemon=True).start()
def _work_explain(self) -> None:
from ..core import ai, gamelogs
from ..core import ai, gamelogs, syslogs
result = self._result
summary = result.summary
@@ -139,6 +139,9 @@ class DiagnosticDialog(QDialog):
logs = gamelogs.collect(since=since) # scoped to this session
if logs:
lines.append("\nGame/Proton/Steam logs for this session:\n" + logs)
sys_logs = syslogs.collect(since=since) # kernel log + crashed-process records
if sys_logs:
lines.append("\nSystem logs for this session (kernel + crashed processes):\n" + sys_logs)
text = "\n".join(lines)
ok, reply = ai.explain(text)
if result.dir: # record exactly what was sent, the model, and the reply (M15)