feat: D12 Steam-launch wrapper for auto crash-capture + doc status fixes — 0.16.0
D12 "build first" wrapper: `rigdoctor wrap %command%` (Steam launch option / Lutris/Heroic wrapper field) auto-brackets a focused diagnostic around a game — start a game-tagged capture on launch, clean stop on exit; a hard freeze leaves it unterminated → flagged as a crash next launch. - core/wrap.py: game name from SteamAppId, PATH-proof launch_option(), run() that doesn't disturb an existing capture and returns the game's exit code. - diagnostic.start() preserves an unanalyzed crash to diagnostic-crash.jsonl before clearing, so auto-relaunch can't wipe an unseen crash; pending_crash/ analyze_crash check the archive first. - GUI: "Auto-capture…" helper dialog (copyable launch-option string). - Tests for wrap (name resolution, exit-code passthrough, no-double-start). - docs: fix stale MODULES.md status column (M1/M3/M4/M5/M8/M10/M13 → done), update ROADMAP/MODULES for the wrapper + crash detection. 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.15.0"
|
||||
__version__ = "0.16.0"
|
||||
|
||||
@@ -417,6 +417,12 @@ def cmd_diagnose(args) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_wrap(args) -> int:
|
||||
from .core import wrap
|
||||
|
||||
return wrap.run(args.command)
|
||||
|
||||
|
||||
def cmd_gameenv(args) -> int:
|
||||
from dataclasses import asdict
|
||||
|
||||
@@ -605,6 +611,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
diag_finish.add_argument("--last", type=int, default=10, help="recent samples to show")
|
||||
diag_finish.set_defaults(func=cmd_diagnose)
|
||||
diag_p.set_defaults(func=cmd_diagnose, diagnose_cmd=None, last=10)
|
||||
|
||||
wrap_p = sub.add_parser(
|
||||
"wrap", help="run a game with automatic crash-capture (Steam launch option, D12)")
|
||||
wrap_p.add_argument("command", nargs=argparse.REMAINDER,
|
||||
help="the game command — use `rigdoctor wrap %%command%%` in Steam")
|
||||
wrap_p.set_defaults(func=cmd_wrap)
|
||||
return p
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ LOG_FILE = LOG_DIR / "capture.jsonl"
|
||||
# Guided diagnostic (M6/D12): a focused capture writes here, separate from the always-on
|
||||
# crash log, so its report covers only that session's window.
|
||||
DIAG_LOG = LOG_DIR / "diagnostic.jsonl"
|
||||
# A crashed (unterminated, unacknowledged) diagnostic is preserved here when a new capture
|
||||
# starts, so auto-capture (the Steam wrapper) relaunching the game doesn't wipe it first.
|
||||
DIAG_CRASH = LOG_DIR / "diagnostic-crash.jsonl"
|
||||
STATUS_FILE = STATE_DIR / "recorder.json"
|
||||
PID_FILE = STATE_DIR / "recorder.pid"
|
||||
SPAWN_LOG = STATE_DIR / "recorder.out"
|
||||
|
||||
@@ -53,6 +53,11 @@ def start(game: str | None = None, interval: float | None = None) -> int | None:
|
||||
Returns the pid, or None if a capture is already running."""
|
||||
if reccontrol.running_pid():
|
||||
return None
|
||||
if _crash_from_log(config.DIAG_LOG): # preserve an unanalyzed crash before overwriting it
|
||||
try:
|
||||
config.DIAG_LOG.replace(config.DIAG_CRASH)
|
||||
except OSError:
|
||||
pass
|
||||
_clear_diag_log()
|
||||
return reccontrol.start_background(interval=interval, out=str(config.DIAG_LOG), game=game)
|
||||
|
||||
@@ -97,17 +102,11 @@ def finish(last_n: int = 10, log_path=None) -> DiagnosticResult:
|
||||
|
||||
# --- hard-crash detection & post-crash analysis -----------------------------------
|
||||
|
||||
def pending_crash() -> CrashInfo | None:
|
||||
"""Detect a diagnostic that ended abnormally (no clean stop, no live recorder).
|
||||
|
||||
A focused capture writes `session-start` (+ `game`) and, on a clean stop, `session-stop`.
|
||||
After a hard freeze that block never runs, so the log has a start with no stop and no
|
||||
live recorder — that's our hard-crash signal. Returns None if a capture is running, none
|
||||
is recorded, it stopped cleanly, or the user already acknowledged it.
|
||||
"""
|
||||
if is_running() or not config.DIAG_LOG.exists():
|
||||
def _crash_from_log(path) -> CrashInfo | None:
|
||||
"""CrashInfo if `path` holds an abnormally-ended session (start, no stop, not acked)."""
|
||||
if not path.exists():
|
||||
return None
|
||||
summary = summarize(config.DIAG_LOG)
|
||||
summary = summarize(path)
|
||||
kinds = {kind for _ts, kind, _detail in summary.events}
|
||||
if "session-start" not in kinds:
|
||||
return None
|
||||
@@ -121,8 +120,34 @@ def pending_crash() -> CrashInfo | None:
|
||||
)
|
||||
|
||||
|
||||
def _crash_path():
|
||||
"""Where the pending crash lives: the preserved archive if present, else the live log."""
|
||||
return config.DIAG_CRASH if config.DIAG_CRASH.exists() else config.DIAG_LOG
|
||||
|
||||
|
||||
def pending_crash() -> CrashInfo | None:
|
||||
"""Detect a diagnostic that ended abnormally (no clean stop, no live recorder).
|
||||
|
||||
A focused capture writes `session-start` (+ `game`) and, on a clean stop, `session-stop`.
|
||||
After a hard freeze that block never runs, so the log has a start with no stop and no
|
||||
live recorder — that's our hard-crash signal. A crash preserved across an auto-relaunch
|
||||
(`DIAG_CRASH`) is checked first. Returns None if a capture is running, none is recorded,
|
||||
it stopped cleanly, or the user already acknowledged it.
|
||||
"""
|
||||
info = _crash_from_log(config.DIAG_CRASH) # preserved across a relaunch (wrapper)
|
||||
if info is not None:
|
||||
return info
|
||||
if is_running():
|
||||
return None
|
||||
return _crash_from_log(config.DIAG_LOG)
|
||||
|
||||
|
||||
def acknowledge_crash() -> None:
|
||||
"""Mark the recorded crash as seen so it stops prompting (appends a marker event)."""
|
||||
"""Mark the recorded crash as seen so it stops prompting."""
|
||||
try:
|
||||
config.DIAG_CRASH.unlink() # drop the preserved archive, if any
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
config.DIAG_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config.DIAG_LOG, "a", encoding="utf-8") as fh:
|
||||
@@ -154,7 +179,7 @@ def analyze_crash(last_n: int = 15) -> DiagnosticResult:
|
||||
+ the rest of the health report (SMART/driver/persistence/temps)."""
|
||||
from .health import check_previous_boot, run_health_checks
|
||||
|
||||
summary = summarize(config.DIAG_LOG, last_n=last_n)
|
||||
summary = summarize(_crash_path(), last_n=last_n)
|
||||
findings: list[Finding] = [_crash_headline(summary)]
|
||||
findings += check_previous_boot() # the crashed boot's kernel log
|
||||
findings += run_health_checks(include_journal=False) # SMART/driver/persistence/temps
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Steam-launch wrapper (D12): auto-bracket a focused diagnostic around a game.
|
||||
|
||||
Set as a per-game Steam launch option — `rigdoctor wrap %command%` — or in Lutris/Heroic's
|
||||
wrapper field. Steam expands `%command%` to the real game command; we start a focused capture
|
||||
(tagged with the game), run the game, and stop the capture cleanly when it exits. A hard
|
||||
freeze means the game (and this wrapper) never returns, so the capture is left without a clean
|
||||
stop — which RigDoctor then flags as a crash on next launch.
|
||||
|
||||
Deterministic and daemonless (D12 "build first"): no polling, and it knows the title.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def game_name_from_env() -> str | None:
|
||||
"""The launching game's name, resolved from Steam's SteamAppId env var via the scan."""
|
||||
appid = os.environ.get("SteamAppId") or os.environ.get("SteamGameId")
|
||||
if not appid:
|
||||
return None
|
||||
from . import steam
|
||||
|
||||
games = steam.cached_games() or steam.scan_games(steam.selected_library_paths())
|
||||
for game in games:
|
||||
if game.appid == str(appid):
|
||||
return game.name
|
||||
return f"Steam app {appid}"
|
||||
|
||||
|
||||
def launch_option() -> str:
|
||||
"""The exact string to paste into Steam's Launch Options (absolute path → PATH-proof)."""
|
||||
exe = Path(sys.executable).with_name("rigdoctor")
|
||||
prog = str(exe) if exe.exists() else "rigdoctor"
|
||||
quoted = f'"{prog}"' if " " in prog else prog
|
||||
return f"{quoted} wrap %command%"
|
||||
|
||||
|
||||
def run(command: list[str]) -> int:
|
||||
"""Start a focused capture (unless one's already running), run the game, then stop it.
|
||||
Returns the game's exit code so Steam sees the right status."""
|
||||
from . import diagnostic, reccontrol
|
||||
|
||||
if not command:
|
||||
print("usage: rigdoctor wrap %command% (set as a Steam launch option)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
game = game_name_from_env() or os.path.basename(command[0])
|
||||
started = False
|
||||
if not reccontrol.running_pid(): # don't disturb an existing capture
|
||||
started = diagnostic.start(game=game) is not None
|
||||
|
||||
proc: subprocess.Popen | None = None
|
||||
|
||||
def _forward(signum, _frame): # pass Steam's stop signal to the game
|
||||
if proc is not None and proc.poll() is None:
|
||||
try:
|
||||
proc.send_signal(signum)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
previous = {sig: signal.signal(sig, _forward) for sig in (signal.SIGTERM, signal.SIGINT)}
|
||||
try:
|
||||
proc = subprocess.Popen(command)
|
||||
rc = proc.wait()
|
||||
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||
print(f"rigdoctor wrap: couldn't launch the game: {exc}", file=sys.stderr)
|
||||
rc = 1
|
||||
finally:
|
||||
for sig, handler in previous.items():
|
||||
signal.signal(sig, handler)
|
||||
if started:
|
||||
reccontrol.stop_background() # clean stop → no false crash flag
|
||||
return rc
|
||||
@@ -13,10 +13,13 @@ import time
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
@@ -99,6 +102,9 @@ class GamesPage(QWidget):
|
||||
self._status = QLabel("")
|
||||
self._status.setObjectName("Muted")
|
||||
header.addWidget(self._status)
|
||||
self._autocap_btn = QPushButton("Auto-capture…")
|
||||
self._autocap_btn.clicked.connect(self._show_autocapture)
|
||||
header.addWidget(self._autocap_btn)
|
||||
self._rescan_btn = QPushButton("Rescan")
|
||||
self._rescan_btn.setObjectName("PrimaryButton")
|
||||
self._rescan_btn.clicked.connect(self.refresh)
|
||||
@@ -395,6 +401,43 @@ class GamesPage(QWidget):
|
||||
reccontrol.stop_background()
|
||||
self._banner.hide()
|
||||
|
||||
def _show_autocapture(self) -> None:
|
||||
from ..core import wrap
|
||||
|
||||
option = wrap.launch_option()
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle("Auto-capture in Steam")
|
||||
dlg.resize(580, 250)
|
||||
v = QVBoxLayout(dlg)
|
||||
v.setContentsMargins(20, 18, 20, 16)
|
||||
v.setSpacing(12)
|
||||
info = QLabel(
|
||||
"Capture automatically every time you launch a game — no need to click "
|
||||
"Run Diagnostic.\n\n"
|
||||
"1. In Steam, right-click the game → Properties → Launch Options.\n"
|
||||
"2. Paste the line below.\n\n"
|
||||
"RigDoctor starts a focused capture when the game launches and stops it on exit. "
|
||||
"If the game hard-freezes, you'll get a crash report next time you open RigDoctor."
|
||||
)
|
||||
info.setWordWrap(True)
|
||||
v.addWidget(info)
|
||||
row = QHBoxLayout()
|
||||
field = QLineEdit(option)
|
||||
field.setReadOnly(True)
|
||||
row.addWidget(field, 1)
|
||||
copy = QPushButton("Copy")
|
||||
copy.setObjectName("PrimaryButton")
|
||||
copy.clicked.connect(lambda: QApplication.clipboard().setText(option))
|
||||
row.addWidget(copy)
|
||||
v.addLayout(row)
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch(1)
|
||||
close = QPushButton("Close")
|
||||
close.clicked.connect(dlg.accept)
|
||||
buttons.addWidget(close)
|
||||
v.addLayout(buttons)
|
||||
dlg.exec()
|
||||
|
||||
# --- hard-crash recovery ----------------------------------------------------------
|
||||
|
||||
def _check_crash(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user