diff --git a/CHANGELOG.md b/CHANGELOG.md index 8922cd9..0f1e04b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to RigDoctor are recorded here. Format follows (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git release tag (so the auto-updater, D18, can compare versions). +## [0.31.0] - 2026-05-22 +### Added +- **Diagnostics now collect session-scoped system logs** (`core/syslogs.py`): a kernel-log + slice (`journalctl -k` — Xid, OOM-killer, MCE, PCIe AER, thermal, hung tasks) and + **crashed-process records** (`coredumpctl` — which executable, signal, and when). They're saved + to the diagnostic directory (`syslogs.txt`), included in the **Report** bundle, and fed to the + AI on "Explain" alongside the game logs. Best-effort — degrades quietly if the tools are + missing or access is denied; scoped to the session window so it doesn't drag in old noise. + ## [0.30.0] - 2026-05-22 ### Added - **Logging & report bundles (M15, D25)** — opt-in via one **Settings → Logging** toggle diff --git a/docs/MODULES.md b/docs/MODULES.md index 26ebcee..8ffea2b 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -133,9 +133,11 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done - **M15 Logging & report bundles** (D25) — opt-in via one `logging_enabled` toggle (default off): application logging to a rotating `app.log` (`core/applog.py`) and **per-diagnostic storage** (`core/diagstore.py`) — each diagnostic gets its own `DATA_DIR/diagnostics//` (capture, - `result.json`, `report.txt`, scoped game logs, and an `ai/` record of every AI interaction: - exact data sent, model, reply). **"Report"** zips one into `DATA_DIR/reports/` (GUI button on - the diagnostic dialog; CLI `rigdoctor bundle`). Stays local; shareable on demand. + `result.json`, `report.txt`, scoped **game logs** (`core/gamelogs.py`) and **system logs** + (`core/syslogs.py` — `journalctl -k` slice + `coredumpctl` crashed-process records), and an + `ai/` record of every AI interaction: exact data sent, model, reply). **"Report"** zips one + into `DATA_DIR/reports/` (GUI button on the diagnostic dialog; CLI `rigdoctor bundle`). All + logs are session-scoped and fed to the AI on "Explain". Stays local; shareable on demand. ## Bundles (final — D14) - **Essential:** M1 + M3 + M4 *(the MVP, NVIDIA-only — D5)* diff --git a/docs/SPEC.md b/docs/SPEC.md index fc2c31d..1b909ea 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -165,8 +165,10 @@ the actual findings plus matched reference facts from a curated, exact-match kno ### M15 — Logging & report bundles (D25) Opt-in (one `logging_enabled` toggle, default off). When on: the application logs to a rotating `app.log`, and **each diagnostic is stored in its own directory** (capture log, structured -result, human-readable report, scoped game logs, and a record of every AI interaction — the -exact data sent, the model, and its reply). A **Report** action zips one diagnostic's directory +result, human-readable report, session-scoped **game logs** (Proton/Steam) and **system logs** +(`journalctl -k` slice + `coredumpctl` crashed-process records), and a record of every AI +interaction — the exact data sent, the model, and its reply). The collected logs are also fed to +the AI on "Explain". System-log collection is best-effort (degrades if tools are missing/denied). A **Report** action zips one diagnostic's directory (plus the app log) into a shareable bundle saved under the reports folder (GUI button; CLI `rigdoctor bundle`). Everything stays local — a report only leaves the machine if the user shares the zip. Stdlib only (`logging` + `zipfile`). diff --git a/pyproject.toml b/pyproject.toml index 0fc98a9..69f5cc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.30.0" +version = "0.31.0" description = "Modular hardware monitoring & crash diagnostics for Linux gamers." readme = "README.md" requires-python = ">=3.11" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index 3bde4a1..f537844 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.30.0" +__version__ = "0.31.0" diff --git a/src/rigdoctor/core/diagstore.py b/src/rigdoctor/core/diagstore.py index d959668..f4ae4fc 100644 --- a/src/rigdoctor/core/diagstore.py +++ b/src/rigdoctor/core/diagstore.py @@ -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 diff --git a/src/rigdoctor/core/syslogs.py b/src/rigdoctor/core/syslogs.py new file mode 100644 index 0000000..f40999c --- /dev/null +++ b/src/rigdoctor/core/syslogs.py @@ -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) diff --git a/src/rigdoctor/gui/diagnostic_dialog.py b/src/rigdoctor/gui/diagnostic_dialog.py index 205ec3c..2200279 100644 --- a/src/rigdoctor/gui/diagnostic_dialog.py +++ b/src/rigdoctor/gui/diagnostic_dialog.py @@ -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) diff --git a/tests/test_syslogs.py b/tests/test_syslogs.py new file mode 100644 index 0000000..4e71056 --- /dev/null +++ b/tests/test_syslogs.py @@ -0,0 +1,54 @@ +"""Tests for M15 session-scoped system-log collection (kernel + coredumps).""" + +import unittest +from unittest import mock + +from rigdoctor.core import syslogs + + +class KernelLogTests(unittest.TestCase): + def test_passes_since_and_tails(self): + with mock.patch("shutil.which", return_value="/usr/bin/journalctl"), \ + mock.patch.object(syslogs, "_run", return_value="X" * 100 + "TAILLINE") as run: + out = syslogs.kernel_log(since=1_000_000_000, max_bytes=8) + self.assertEqual(out, "TAILLINE") + cmd = run.call_args[0][0] + self.assertIn("-k", cmd) + self.assertIn("--since", cmd) + + def test_missing_tool_returns_empty(self): + with mock.patch("shutil.which", return_value=None): + self.assertEqual(syslogs.kernel_log(), "") + + +class CoredumpTests(unittest.TestCase): + def test_empty_when_no_coredumps(self): + with mock.patch("shutil.which", return_value="/usr/bin/coredumpctl"), \ + mock.patch.object(syslogs, "_run", return_value="No coredumps found."): + self.assertEqual(syslogs.coredumps(), "") + + def test_returns_list(self): + with mock.patch("shutil.which", return_value="/usr/bin/coredumpctl"), \ + mock.patch.object(syslogs, "_run", return_value="TIME PID SIG EXE\n... SEGV PathOfExile"): + out = syslogs.coredumps() + self.assertIn("PathOfExile", out) + + +class CollectTests(unittest.TestCase): + def test_collect_combines_sections(self): + with mock.patch.object(syslogs, "kernel_log", return_value="NVRM: Xid 79"), \ + mock.patch.object(syslogs, "coredumps", return_value="game SIGSEGV"): + out = syslogs.collect() + self.assertIn("Kernel log", out) + self.assertIn("Xid 79", out) + self.assertIn("Crashed processes", out) + self.assertIn("SIGSEGV", out) + + def test_collect_empty_when_nothing(self): + with mock.patch.object(syslogs, "kernel_log", return_value=""), \ + mock.patch.object(syslogs, "coredumps", return_value=""): + self.assertEqual(syslogs.collect(), "") + + +if __name__ == "__main__": + unittest.main()