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:
@@ -1,3 +1,3 @@
|
||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||
|
||||
__version__ = "0.30.0"
|
||||
__version__ = "0.31.0"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user