From 1aa7e51be712f82697823cd7f7b73c6723b4ed7f Mon Sep 17 00:00:00 2001 From: JamesAbel Date: Sun, 23 Aug 2026 09:56:45 -0700 Subject: [PATCH 1/4] Arm faulthandler in every process and surface crashes at the next launch A native crash (access violation in python.dll) killed the GUI process twice with zero diagnostic residue. faults.py arms faulthandler in the parent and every spawn child (via PYTEST_FLY_FAULTHANDLER), writes dumps to logs/faulthandler-.log, and report_previous_crashes() logs and archives non-empty dumps at startup. pytest's own faulthandler plugin is disabled in the test child so one file per PID stays authoritative. platform/wer.py reads the Windows Error Reporting LocalDumps state, builds the elevated configure/remove commands, and sweeps new *.dmp files at startup. New prefs: faulthandler_enabled, wer_*, coverage_* (UI wiring follows). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018j7yAH7CooFHFLEANWM5vM --- src/pytest_fly/const.py | 4 + src/pytest_fly/faults.py | 116 +++++++++++ src/pytest_fly/main.py | 10 + src/pytest_fly/platform/wer.py | 186 ++++++++++++++++++ src/pytest_fly/preferences.py | 17 ++ .../pytest_runner/process_monitor.py | 2 + .../pytest_runner/pytest_process.py | 12 +- .../pytest_runner/system_monitor.py | 2 + src/pytest_fly/pytest_runner/test_list.py | 2 + tests/test_faults.py | 118 +++++++++++ tests/test_wer.py | 76 +++++++ 11 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 src/pytest_fly/faults.py create mode 100644 src/pytest_fly/platform/wer.py create mode 100644 tests/test_faults.py create mode 100644 tests/test_wer.py diff --git a/src/pytest_fly/const.py b/src/pytest_fly/const.py index 70a9fad..459beaf 100644 --- a/src/pytest_fly/const.py +++ b/src/pytest_fly/const.py @@ -7,3 +7,7 @@ # from) into spawned child processes, which re-import modules fresh and so lose the in-process # binding established by paths.init_workspace(). PYTEST_FLY_WORKSPACE_STRING = "PYTEST_FLY_WORKSPACE" + +# Environment variable carrying the resolved faulthandler setting into spawned child processes, +# which re-import modules fresh and so cannot see the parent's preference lookup. +PYTEST_FLY_FAULTHANDLER_STRING = "PYTEST_FLY_FAULTHANDLER" diff --git a/src/pytest_fly/faults.py b/src/pytest_fly/faults.py new file mode 100644 index 0000000..6978c01 --- /dev/null +++ b/src/pytest_fly/faults.py @@ -0,0 +1,116 @@ +"""Fatal-error diagnostics. + +``faulthandler`` writes the Python stack of every thread to a file descriptor when the process +dies from SIGSEGV / SIGABRT / SIGFPE / SIGBUS / SIGILL (on Windows, also an access violation). +It writes directly to the fd from the signal handler, so the output survives a crash that leaves +no traceback and no log record. + +The dump file is opened once per process and the handle is held for the process lifetime — +closing it would leave ``faulthandler`` writing to a dead descriptor. A clean exit leaves an +empty file; :func:`report_previous_crashes` sweeps those up on the next launch and surfaces any +non-empty one (a crash) in the log. +""" + +import faulthandler +import os +from pathlib import Path + +from tobool import to_bool + +from .const import PYTEST_FLY_FAULTHANDLER_STRING +from .logger import EVENT_EXTRA, get_logger +from .paths import get_log_dir + +log = get_logger() + +_fault_file = None # process-lifetime reference; never close it + +_dump_prefix = "faulthandler-" +_crash_prefix = "faulthandler-crash-" +_dump_suffix = ".log" + + +def faulthandler_dump_path(pid: int | None = None) -> Path: + """Path of a process's faulthandler dump file (this process when *pid* is ``None``).""" + return Path(get_log_dir(), f"{_dump_prefix}{os.getpid() if pid is None else pid}{_dump_suffix}") + + +def faulthandler_enabled_by_env() -> bool: + """Whether faulthandler is requested, per the inherited environment. + + The parent stamps ``PYTEST_FLY_FAULTHANDLER`` into ``os.environ`` at startup (see + :func:`enable_faulthandler`), so spawn children need no preference lookup of their own. + Unset means enabled — the handler must be armed before the crash nobody predicted. + """ + value = os.environ.get(PYTEST_FLY_FAULTHANDLER_STRING) + if value is None: + return True + parsed = to_bool(value) + return True if parsed is None else parsed # an unrecognized value keeps the safe default: armed + + +def enable_faulthandler(export_to_children: bool = False, requested: bool | None = None) -> Path | None: + """Install the fatal-error handler for this process. + + :param export_to_children: parent only — stamp the resolved setting into ``os.environ`` so + spawned children inherit it without reading preferences. + :param requested: explicit on/off; ``None`` means "read the environment". + :return: the dump file path, or ``None`` if faulthandler is disabled or could not be enabled. + """ + global _fault_file + enabled = faulthandler_enabled_by_env() if requested is None else requested + if export_to_children: + os.environ[PYTEST_FLY_FAULTHANDLER_STRING] = "1" if enabled else "0" + if not enabled: + return None + path = faulthandler_dump_path() + try: + # binary, append, unbuffered: faulthandler writes to the raw fd, and append means a + # re-enable in the same process never truncates an earlier dump. + _fault_file = open(path, "ab", buffering=0) + faulthandler.enable(file=_fault_file, all_threads=True) + except OSError as e: + log.warning(f"could not enable faulthandler at {path}: {e}") + return None + return path + + +def report_previous_crashes(max_chars: int = 4000) -> list[Path]: + """Log and archive any non-empty faulthandler dumps left by earlier sessions. + + A non-empty dump means some process died from a fatal signal. Each is logged at WARNING (so + it reaches the log file and the GUI Log tab) and renamed to ``faulthandler-crash--.log`` + so it is reported exactly once but never destroyed. Empty dumps from prior runs (clean exits) + are deleted. A file still held by a live process (e.g. a second instance) is skipped. + + :param max_chars: cap on how much of each dump is echoed into the log. + :return: the archived crash-dump paths. + """ + archived: list[Path] = [] + log_dir = get_log_dir() + own_path = faulthandler_dump_path() + for path in sorted(log_dir.glob(f"{_dump_prefix}*{_dump_suffix}")): + if path == own_path or path.name.startswith(_crash_prefix): + continue + try: + if path.stat().st_size == 0: + path.unlink() + continue + text = path.read_text(encoding="utf-8", errors="replace") + pid = path.name[len(_dump_prefix) : -len(_dump_suffix)] + archive = _next_archive_path(log_dir, pid) + path.rename(archive) + except OSError as e: + log.info(f"skipping faulthandler dump {path}: {e}") # locked by a live process, or already gone + continue + log.warning(f"previous session crashed ({archive.name}):\n{text[:max_chars]}", extra=EVENT_EXTRA) + archived.append(archive) + return archived + + +def _next_archive_path(log_dir: Path, pid: str) -> Path: + """First unused ``faulthandler-crash--.log`` path (PIDs are reused, so number them).""" + n = 1 + while (candidate := Path(log_dir, f"{_crash_prefix}{pid}-{n}{_dump_suffix}")).exists(): + n += 1 + return candidate diff --git a/src/pytest_fly/main.py b/src/pytest_fly/main.py index 2364fca..ea88bb0 100644 --- a/src/pytest_fly/main.py +++ b/src/pytest_fly/main.py @@ -5,9 +5,11 @@ from pathlib import Path from .__version__ import application_name +from .faults import enable_faulthandler, report_previous_crashes from .gui import fly_main from .logger import get_logger, init_parent_logger from .paths import get_default_data_dir, get_workspace_dir, init_workspace +from .platform.wer import report_previous_crash_dumps from .preferences import get_active_put_path, get_pref, set_active_put_path from .project_info import get_project_info from .put_version import detect_put_version @@ -59,6 +61,14 @@ def app_main(argv: list[str] | None = None): log.info(f"workspace: {get_workspace_dir()}") log.info(f"program under test path: {put_path}") + # Arm fatal-signal diagnostics before anything else runs, then surface whatever an earlier + # session left behind — a native crash otherwise leaves no user-visible trace at all. + dump_path = enable_faulthandler(export_to_children=True, requested=pref.faulthandler_enabled) + if dump_path is not None: + log.info(f"faulthandler armed: {dump_path}") + report_previous_crashes() + report_previous_crash_dumps() + project_info = get_project_info() log.info(f"{project_info.application_name} version {project_info.version}") diff --git a/src/pytest_fly/platform/wer.py b/src/pytest_fly/platform/wer.py new file mode 100644 index 0000000..564e31d --- /dev/null +++ b/src/pytest_fly/platform/wer.py @@ -0,0 +1,186 @@ +"""Windows Error Reporting (WER) *LocalDumps* support. + +When a process dies from a native fault (e.g. an access violation inside ``python314.dll``), +WER produces a minidump — and then discards it unless ``LocalDumps`` is configured. That +configuration lives under ``HKLM`` and so requires elevation to write; pytest-fly can *read* it +and *offer* to apply it (via a UAC prompt), but never applies it silently. + +The setting is keyed on the bare image name (``python.exe``), so it is machine-wide and covers +every Python process on the box, not just pytest-fly. The UI says so. + +Everything here imports cleanly on non-Windows: ``winreg`` and the ``ctypes`` shell call are +confined to Windows-only branches. +""" + +import ctypes +import time +from dataclasses import dataclass +from pathlib import Path + +from ..logger import EVENT_EXTRA, get_logger +from ..paths import get_fly_data_dir +from .os import is_windows + +log = get_logger() + +LOCAL_DUMPS_KEY = r"SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" +DEFAULT_IMAGE_NAME = "python.exe" +DUMP_TYPE_MINIDUMP = 1 +DUMP_TYPE_FULL = 2 +_crash_dump_subdir_name = "crashdumps" +_shell_execute_launched_threshold = 32 # ShellExecuteW returns > 32 on success + + +@dataclass(frozen=True) +class WerLocalDumpsConfig: + """Current LocalDumps registry state for one image name.""" + + image_name: str + configured: bool + dump_folder: str | None + dump_count: int | None + dump_type: int | None + + def describe(self) -> str: + """One-line human-readable status for the Configuration tab.""" + if not self.configured: + return f"Not configured — Windows discards crash dumps for {self.image_name}" + type_name = {DUMP_TYPE_MINIDUMP: "minidump", DUMP_TYPE_FULL: "full"}.get(self.dump_type or 0, str(self.dump_type)) + folder = self.dump_folder or "%LOCALAPPDATA%\\CrashDumps (default)" + count = self.dump_count if self.dump_count is not None else "10 (default)" + return f"{self.image_name} → {folder}, type={type_name}, count={count}" + + +def default_wer_dump_folder() -> Path: + """``/.pytest-fly/crashdumps`` — crash artifacts sit with the logs and results DB.""" + return Path(get_fly_data_dir(), _crash_dump_subdir_name) + + +def read_wer_local_dumps(image_name: str = DEFAULT_IMAGE_NAME) -> WerLocalDumpsConfig: + """Read the LocalDumps subkey for *image_name*. Unprivileged; never raises. + + On non-Windows platforms returns ``configured=False`` without touching ``winreg``. + """ + unconfigured = WerLocalDumpsConfig(image_name, False, None, None, None) + if not is_windows(): + return unconfigured + import winreg # Windows-only module + + try: + key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, f"{LOCAL_DUMPS_KEY}\\{image_name}") + except FileNotFoundError: + return unconfigured + except OSError as e: + log.info(f"could not read WER LocalDumps for {image_name}: {e}") + return unconfigured + with key: + return WerLocalDumpsConfig( + image_name, + True, + _read_value(key, "DumpFolder"), + _read_value(key, "DumpCount"), + _read_value(key, "DumpType"), + ) + + +def _read_value(key, name: str): + """Return a registry value, or ``None`` when it is absent (WER then uses its default).""" + import winreg # Windows-only module + + try: + value, _unused_type = winreg.QueryValueEx(key, name) + except FileNotFoundError: + return None + except OSError as e: + log.info(f"could not read WER LocalDumps value {name}: {e}") + return None + return value + + +def wer_configure_command(image_name: str, dump_folder: Path, dump_count: int, dump_type: int) -> str: + """Return the elevated PowerShell command that would apply this configuration. + + Shown in the UI and offered for copy-to-clipboard, so the user can inspect exactly what will + be written to HKLM before agreeing to run it. The folder is single-quoted (PowerShell + literal string) because workspace paths commonly contain spaces. + """ + key = f"HKLM:\\{LOCAL_DUMPS_KEY}\\{image_name}" + folder = str(dump_folder).replace("'", "''") + return ( + f"New-Item -Path '{key}' -Force | Out-Null; " + f"New-ItemProperty -Path '{key}' -Name DumpFolder -PropertyType ExpandString -Value '{folder}' -Force | Out-Null; " + f"New-ItemProperty -Path '{key}' -Name DumpCount -PropertyType DWord -Value {int(dump_count)} -Force | Out-Null; " + f"New-ItemProperty -Path '{key}' -Name DumpType -PropertyType DWord -Value {int(dump_type)} -Force | Out-Null" + ) + + +def wer_remove_command(image_name: str) -> str: + """Return the elevated PowerShell command that deletes the LocalDumps subkey for *image_name*.""" + key = f"HKLM:\\{LOCAL_DUMPS_KEY}\\{image_name}" + return f"Remove-Item -Path '{key}' -Recurse -Force" + + +def _run_elevated_powershell(command: str) -> bool: + """Launch *command* in an elevated PowerShell via ShellExecuteW "runas". + + Returns whether the elevated process was *launched* — UAC consent and the registry write + happen out-of-process, so callers must re-read the registry to learn the actual result. + Deliberately a thin wrapper: it cannot be unit-tested without admin rights and a machine-state + mutation, so it is kept obviously correct instead. + """ + if not is_windows(): + return False + args = f'-NoProfile -NonInteractive -Command "{command}"' + result = ctypes.windll.shell32.ShellExecuteW(None, "runas", "powershell.exe", args, None, 0) # type: ignore[attr-defined] + launched = int(result) > _shell_execute_launched_threshold + if not launched: + log.warning(f"elevated PowerShell launch failed (ShellExecuteW returned {result}; 5 = UAC declined)") + return launched + + +def apply_wer_local_dumps_elevated(image_name: str, dump_folder: Path, dump_count: int, dump_type: int) -> bool: + """Launch the configure command elevated (UAC prompt). See :func:`_run_elevated_powershell`.""" + try: + dump_folder.mkdir(parents=True, exist_ok=True) # WER does not reliably create a missing tree + except OSError as e: + log.warning(f"could not create WER dump folder {dump_folder}: {e}") + return _run_elevated_powershell(wer_configure_command(image_name, dump_folder, dump_count, dump_type)) + + +def remove_wer_local_dumps_elevated(image_name: str) -> bool: + """Launch an elevated delete of the LocalDumps subkey for *image_name*.""" + return _run_elevated_powershell(wer_remove_command(image_name)) + + +def report_previous_crash_dumps() -> list[Path]: + """Log any ``*.dmp`` files in the configured dump folder that are newer than the last sweep. + + A dump nobody knows exists is no better than no dump. Each new file is logged once at WARNING + (visible in the Log tab), then the sweep timestamp preference advances past it. + + :return: the newly reported dump paths. + """ + from ..preferences import get_pref # deferred: preferences imports this package + + pref = get_pref() + folder = Path(pref.wer_dump_folder) if pref.wer_dump_folder else default_wer_dump_folder() + reported: list[Path] = [] + newest = pref.last_crash_dump_sweep + if folder.is_dir(): + try: + candidates = sorted(folder.glob("*.dmp")) + except OSError as e: + log.info(f"could not scan crash dump folder {folder}: {e}") + candidates = [] + for path in candidates: + try: + stat = path.stat() + except OSError: + continue + if stat.st_mtime > pref.last_crash_dump_sweep: + log.warning(f"crash dump from a previous session: {path} ({stat.st_size / 1e6:.1f} MB, {time.ctime(stat.st_mtime)})", extra=EVENT_EXTRA) + reported.append(path) + newest = max(newest, stat.st_mtime) + if newest != pref.last_crash_dump_sweep: + pref.last_crash_dump_sweep = newest + return reported diff --git a/src/pytest_fly/preferences.py b/src/pytest_fly/preferences.py index a32df66..fe5cc55 100644 --- a/src/pytest_fly/preferences.py +++ b/src/pytest_fly/preferences.py @@ -32,6 +32,12 @@ graph_font_size_default = 10 # point size of the font used in the Progress Graph tab log_tab_line_limit_default = 10_000 # max lines retained/displayed in the Log tab — bounds memory over a long session history_run_limit_default = 10 # number of recent runs summarized in the History tab +faulthandler_enabled_default = True # write a Python stack dump for every thread on a fatal signal (cheap; leave on) +coverage_refresh_seconds_default = 30.0 # minimum seconds between combined-coverage recalculations during a run (0 = after every test completion) +coverage_timeout_seconds_default = 300.0 # kill the out-of-process coverage aggregation if it runs longer than this +wer_dump_folder_default = "" # WER LocalDumps folder; empty = /.pytest-fly/crashdumps +wer_dump_type_default = 1 # WER DumpType: 1 = minidump (stacks + modules, a few MB), 2 = full dump (includes the heap; can be many GB) +wer_dump_count_default = 3 # WER DumpCount: how many dumps Windows keeps before evicting the oldest # Time-duration units offered for the stall timeouts. Stored as a (value, unit) pair so the # user can express a timeout in whichever unit reads best; converted to seconds for the runner. @@ -161,6 +167,17 @@ class FlyPreferences(Pref): history_run_limit: int = attrib(default=history_run_limit_default) # number of recent runs summarized in the History tab + # Crash diagnostics (see faults.py / platform/wer.py). + faulthandler_enabled: bool = attrib(default=faulthandler_enabled_default) # dump all thread stacks on a fatal signal + wer_dump_folder: str = attrib(default=wer_dump_folder_default) # Windows Error Reporting LocalDumps folder (empty = workspace default) + wer_dump_type: int = attrib(default=wer_dump_type_default) # 1 = minidump, 2 = full dump + wer_dump_count: int = attrib(default=wer_dump_count_default) # dumps retained before the oldest is evicted + last_crash_dump_sweep: float = attrib(default=0.0) # wall-clock of the last WER dump-folder sweep; only newer .dmp files are reported + + # Coverage aggregation (see gui/coverage_tracker.py). + coverage_refresh_seconds: float = attrib(default=coverage_refresh_seconds_default) # minimum seconds between recalculations (0 = every completion) + coverage_timeout_seconds: float = attrib(default=coverage_timeout_seconds_default) # aggregation child process timeout + # Wall-clock start of the most recent run; the Progress Graph time-axis origin, restored on # restart so RESUME-carried records still shift onto the run timeline (0.0 = none). last_run_start: float = attrib(default=0.0) diff --git a/src/pytest_fly/pytest_runner/process_monitor.py b/src/pytest_fly/pytest_runner/process_monitor.py index 34d1147..e93bea7 100644 --- a/src/pytest_fly/pytest_runner/process_monitor.py +++ b/src/pytest_fly/pytest_runner/process_monitor.py @@ -12,6 +12,7 @@ from psutil import Process as PsutilProcess from typeguard import typechecked +from ..faults import enable_faulthandler from ..logger import configure_child_logger from .commit_memory import subtree_commit @@ -116,6 +117,7 @@ def __init__(self, run_guid: str, name: str, pid: int, update_rate: float): def run(self): """Sample CPU and memory at ``_update_rate`` intervals until stop is requested.""" configure_child_logger(f"process_monitor-{self._pid}.log") + enable_faulthandler() # reads PYTEST_FLY_FAULTHANDLER from the inherited environment psutil_process = PsutilProcess(self._pid) diff --git a/src/pytest_fly/pytest_runner/pytest_process.py b/src/pytest_fly/pytest_runner/pytest_process.py index c1b2c51..4a149ec 100644 --- a/src/pytest_fly/pytest_runner/pytest_process.py +++ b/src/pytest_fly/pytest_runner/pytest_process.py @@ -19,6 +19,7 @@ from typeguard import typechecked from ..db import PytestProcessInfoDB +from ..faults import enable_faulthandler, faulthandler_enabled_by_env from ..file_util import sanitize_test_name from ..interfaces import PyTestFlyExitCode, PytestProcessInfo, int_exit_code_to_pytest_fly_exit_code from ..logger import configure_child_logger, get_logger @@ -251,6 +252,7 @@ def _open_live_output(self, live_path: Path, retry_timeout: float = 5.0, retry_i def run(self) -> None: configure_child_logger(f"{sanitize_test_name(self.name)}.log") + enable_faulthandler() # reads PYTEST_FLY_FAULTHANDLER from the inherited environment # start the process monitor to monitor things like CPU and memory usage self._process_monitor_process = ProcessMonitor(self.run_guid, self.name, self.pid, self.update_rate) @@ -292,7 +294,15 @@ def run(self) -> None: try: # -rA: show full short test summary (all outcomes, untruncated assertion messages) # -s: disable pytest capture so stdout/stderr stream live to the log file - pytest_exit_code = pytest.main([self.name, "-rA", "-s"]) + pytest_args = [self.name, "-rA", "-s"] + if faulthandler_enabled_by_env(): + # pytest's own faulthandler plugin re-points the handler at its copy of + # stderr (the live-output file here) for the duration of the session, + # which would split a crash dump across two files. Keep this process's + # faulthandler-.log authoritative so the post-crash sweep finds it. + # Costs pytest's faulthandler_timeout feature, which pytest-fly does not use. + pytest_args.extend(["-p", "no:faulthandler"]) + pytest_exit_code = pytest.main(pytest_args) exit_code = int_exit_code_to_pytest_fly_exit_code(pytest_exit_code) except Exception: # deliberate broad catch — see comment # pytest.main executes arbitrary user/plugin code, so no exception diff --git a/src/pytest_fly/pytest_runner/system_monitor.py b/src/pytest_fly/pytest_runner/system_monitor.py index a438275..2eb6d4e 100644 --- a/src/pytest_fly/pytest_runner/system_monitor.py +++ b/src/pytest_fly/pytest_runner/system_monitor.py @@ -14,6 +14,7 @@ import psutil from typeguard import typechecked +from ..faults import enable_faulthandler from ..logger import configure_child_logger from .commit_memory import commit_charge_and_limit from .const import BYTES_PER_GB as _BYTES_PER_GB @@ -57,6 +58,7 @@ def __init__(self, update_rate: float = 1.0): def run(self): """Sample resources at ``_update_rate`` intervals until stop is requested.""" configure_child_logger("system_monitor.log") + enable_faulthandler() # reads PYTEST_FLY_FAULTHANDLER from the inherited environment psutil.cpu_percent(interval=None) # prime psutil's CPU counter; ignore the first 0.0 prev_disk = psutil.disk_io_counters() diff --git a/src/pytest_fly/pytest_runner/test_list.py b/src/pytest_fly/pytest_runner/test_list.py index 1cc76b5..95b0517 100644 --- a/src/pytest_fly/pytest_runner/test_list.py +++ b/src/pytest_fly/pytest_runner/test_list.py @@ -13,6 +13,7 @@ import pytest from typeguard import typechecked +from ..faults import enable_faulthandler from ..interfaces import ScheduledTest from ..logger import configure_child_logger, get_logger @@ -47,6 +48,7 @@ def run(self): this spawned child process. """ configure_child_logger("get_tests.log") + enable_faulthandler() # reads PYTEST_FLY_FAULTHANDLER from the inherited environment log.info(f"{self.test_dir=}") # Collection only needs core pytest. Third-party plugins installed in the venv diff --git a/tests/test_faults.py b/tests/test_faults.py new file mode 100644 index 0000000..e5bdea3 --- /dev/null +++ b/tests/test_faults.py @@ -0,0 +1,118 @@ +"""Tests for pytest_fly.faults — faulthandler arming and the post-crash sweep.""" + +import faulthandler +import logging +import multiprocessing +import os +from pathlib import Path + +import pytest + +from pytest_fly import faults +from pytest_fly.const import PYTEST_FLY_FAULTHANDLER_STRING +from pytest_fly.paths import get_log_dir, init_workspace + + +@pytest.fixture +def fault_workspace(tmp_path, monkeypatch): + """Isolated workspace plus a clean environment, restoring the session workspace afterwards.""" + from pytest_fly import paths + + previous = paths.get_workspace_dir() + monkeypatch.delenv(PYTEST_FLY_FAULTHANDLER_STRING, raising=False) + init_workspace(tmp_path) + was_enabled = faulthandler.is_enabled() + yield tmp_path + faulthandler.disable() + if was_enabled: + faulthandler.enable() + init_workspace(previous) + + +def test_enabled_by_env_defaults_true(fault_workspace, monkeypatch): + assert faults.faulthandler_enabled_by_env() is True + for falsy in ("0", "false", "no"): + monkeypatch.setenv(PYTEST_FLY_FAULTHANDLER_STRING, falsy) + assert faults.faulthandler_enabled_by_env() is False + monkeypatch.setenv(PYTEST_FLY_FAULTHANDLER_STRING, "1") + assert faults.faulthandler_enabled_by_env() is True + + +def test_disabled_creates_nothing_and_leaves_env_alone(fault_workspace): + assert faults.enable_faulthandler(requested=False) is None + assert not faults.faulthandler_dump_path().exists() + assert PYTEST_FLY_FAULTHANDLER_STRING not in os.environ + + +def test_enabled_creates_dump_file(fault_workspace): + path = faults.enable_faulthandler(requested=True) + assert path == faults.faulthandler_dump_path() + assert path.exists() + assert faulthandler.is_enabled() + + +def test_export_to_children_stamps_environment(fault_workspace): + faults.enable_faulthandler(export_to_children=True, requested=False) + assert os.environ[PYTEST_FLY_FAULTHANDLER_STRING] == "0" + faults.enable_faulthandler(export_to_children=True, requested=True) + assert os.environ[PYTEST_FLY_FAULTHANDLER_STRING] == "1" + + +def test_report_previous_crashes(fault_workspace, caplog): + log_dir = get_log_dir() + empty = log_dir / "faulthandler-111.log" + empty.write_bytes(b"") + crashed = log_dir / "faulthandler-222.log" + crashed.write_text('Fatal Python error: Segmentation fault\n\nThread 0x0001 (most recent call first):\n File "x.py", line 1 in f\n') + own = faults.faulthandler_dump_path() + own.write_text("must be skipped: this is the live file") + + with caplog.at_level(logging.WARNING): + archived = faults.report_previous_crashes() + + assert not empty.exists() # clean exit: deleted + assert not crashed.exists() # renamed, never destroyed + assert archived == [log_dir / "faulthandler-crash-222-1.log"] + assert archived[0].read_text().startswith("Fatal Python error") + assert own.exists() # own live file untouched + assert any("previous session crashed" in r.message and "Segmentation fault" in r.message for r in caplog.records) + assert all(getattr(r, "fly_event", False) for r in caplog.records if "previous session crashed" in r.message) + + # Idempotent: nothing new to report, the archive is not re-reported or renamed again. + assert faults.report_previous_crashes() == [] + assert archived[0].exists() + + +def test_archive_numbering_never_overwrites(fault_workspace): + log_dir = get_log_dir() + (log_dir / "faulthandler-crash-333-1.log").write_text("older crash, same pid reused") + (log_dir / "faulthandler-333.log").write_text("newer crash") + archived = faults.report_previous_crashes() + assert archived == [log_dir / "faulthandler-crash-333-2.log"] + assert (log_dir / "faulthandler-crash-333-1.log").read_text() == "older crash, same pid reused" + + +def _crash_child() -> None: + """Spawn-child body: arm the handler from the inherited environment, then die from a real fault.""" + from pytest_fly.faults import enable_faulthandler + + enable_faulthandler() + faulthandler._sigsegv() # noqa: SLF001 — the documented test hook for a genuine segfault + + +def test_child_crash_leaves_named_dump(fault_workspace, monkeypatch): + """End to end: a child that segfaults for real leaves a non-empty dump naming its frame.""" + monkeypatch.setenv(PYTEST_FLY_FAULTHANDLER_STRING, "1") + child = multiprocessing.get_context("spawn").Process(target=_crash_child) + child.start() + child.join(120) + assert not child.is_alive() + assert child.exitcode != 0 + dump = faults.faulthandler_dump_path(child.pid) + assert dump.exists() + text = dump.read_text(errors="replace") + assert "_crash_child" in text + assert Path(dump).stat().st_size > 0 + # And the next launch's sweep surfaces it. + archived = faults.report_previous_crashes() + assert len(archived) == 1 and "_crash_child" in archived[0].read_text(errors="replace") diff --git a/tests/test_wer.py b/tests/test_wer.py new file mode 100644 index 0000000..d780285 --- /dev/null +++ b/tests/test_wer.py @@ -0,0 +1,76 @@ +"""Tests for pytest_fly.platform.wer — read-only and command-building paths. + +The elevated write is deliberately untested: it needs admin rights and mutates machine state. +""" + +import logging +import time +from pathlib import Path + +from pytest_fly.platform import wer +from pytest_fly.preferences import get_pref + + +def test_read_unconfigured_image_is_safe(): + """A subkey that cannot exist reads as unconfigured, with no exception on any platform.""" + config = wer.read_wer_local_dumps("pytest-fly-no-such-image-xyz.exe") + assert config.configured is False + assert config.dump_folder is None and config.dump_count is None and config.dump_type is None + assert "Not configured" in config.describe() + + +def test_read_on_non_windows_skips_winreg(monkeypatch): + monkeypatch.setattr(wer, "is_windows", lambda: False) + assert wer.read_wer_local_dumps().configured is False + assert wer._run_elevated_powershell("anything") is False + + +def test_configure_command_is_stable_and_quotes_spaces(): + command = wer.wer_configure_command("python.exe", Path(r"C:\my work space\dumps"), 3, 1) + assert command == wer.wer_configure_command("python.exe", Path(r"C:\my work space\dumps"), 3, 1) + assert r"LocalDumps\python.exe" in command + assert "'C:\\my work space\\dumps'" in command + assert "-Name DumpCount -PropertyType DWord -Value 3" in command + assert "-Name DumpType -PropertyType DWord -Value 1" in command + assert "-Name DumpFolder -PropertyType ExpandString" in command + + +def test_remove_command_targets_image_key(): + assert wer.wer_remove_command("python.exe").startswith("Remove-Item -Path 'HKLM:\\") + assert "LocalDumps\\python.exe'" in wer.wer_remove_command("python.exe") + + +def test_describe_configured(): + config = wer.WerLocalDumpsConfig("python.exe", True, r"C:\dumps", 3, wer.DUMP_TYPE_MINIDUMP) + assert config.describe() == r"python.exe → C:\dumps, type=minidump, count=3" + + +def test_report_previous_crash_dumps_reports_new_files_once(tmp_path, caplog): + pref = get_pref() + pref.wer_dump_folder = str(tmp_path) + pref.last_crash_dump_sweep = 0.0 + dump = tmp_path / "python.exe.1234.dmp" + dump.write_bytes(b"MDMP" + b"\0" * 100) + (tmp_path / "unrelated.txt").write_text("ignored") + + with caplog.at_level(logging.WARNING): + reported = wer.report_previous_crash_dumps() + assert reported == [dump] + assert any("crash dump from a previous session" in r.message and "1234" in r.message for r in caplog.records) + assert pref.last_crash_dump_sweep >= dump.stat().st_mtime + + # Second sweep: nothing new. + assert wer.report_previous_crash_dumps() == [] + + # A newer dump is picked up. + time.sleep(0.05) + newer = tmp_path / "python.exe.5678.dmp" + newer.write_bytes(b"MDMP") + future = pref.last_crash_dump_sweep + 1.0 + import os + + os.utime(newer, (future, future)) + assert wer.report_previous_crash_dumps() == [newer] + + pref.wer_dump_folder = "" + pref.last_crash_dump_sweep = 0.0 From d6ff0538264f66ac58e8d496795889616f086703 Mon Sep 17 00:00:00 2001 From: JamesAbel Date: Sun, 23 Aug 2026 09:56:56 -0700 Subject: [PATCH 2/4] Single cov.report() pass in calculate_coverage() Coverage.report() returns the total percentage for every output_format, so the total-only pass was a complete second parse of every PUT source file just to read a number the text pass already returns. Halves the cost of the step that was on the stack when the process died. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018j7yAH7CooFHFLEANWM5vM --- src/pytest_fly/pytest_runner/coverage.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/pytest_fly/pytest_runner/coverage.py b/src/pytest_fly/pytest_runner/coverage.py index 8f56a43..5c351e6 100644 --- a/src/pytest_fly/pytest_runner/coverage.py +++ b/src/pytest_fly/pytest_runner/coverage.py @@ -132,13 +132,12 @@ def calculate_coverage(test_identifier: str, coverage_parent_directory: Path, wr cov.combine(coverage_files_as_strings, keep=True) cov.save() - # Get percentage from total-only report - total_buffer = io.StringIO() - coverage_value = cov.report(ignore_errors=True, output_format="total", file=total_buffer) / 100.0 - - # Get statement counts from the full text report + # One report pass: the return value is the total percentage (for every output format), + # and the text body carries the per-file rows we parse the TOTAL line out of. + # coverage.report() re-parses every source file in the PUT, so a second call purely to + # read the percentage would double the most expensive step here. report_buffer = io.StringIO() - cov.report(ignore_errors=True, file=report_buffer) + coverage_value = cov.report(ignore_errors=True, file=report_buffer) / 100.0 total_statements, missing = _parse_report_totals(report_buffer.getvalue()) covered_statements = total_statements - missing From 671ee408ba6ac7f5e89681aef1adfabe463c0cc2 Mon Sep 17 00:00:00 2001 From: JamesAbel Date: Sun, 23 Aug 2026 09:57:13 -0700 Subject: [PATCH 3/4] Run coverage aggregation in a child process, rate-limited CoverageAggregator (spawn child) runs calculate_coverage() out of the GUI process; aggregate_coverage() joins it with a timeout (coverage_timeout_seconds pref) and treats a crashed, killed, or hung child as a logged warning that keeps the last good values. CoverageTracker holds new completed-test sets for coverage_refresh_seconds (default 30) while the run is active and always does a final pass once nothing is running or queued. The Coverage tab's HTML report goes through the same child, polled from a timer so the GUI no longer blocks. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018j7yAH7CooFHFLEANWM5vM --- .../gui/coverage_tab/coverage_tab.py | 68 +++++-- src/pytest_fly/gui/coverage_tracker.py | 56 ++++-- .../pytest_runner/coverage_aggregator.py | 74 ++++++++ tests/test_coverage_aggregator.py | 173 ++++++++++++++++++ tests/test_coverage_tab_report.py | 54 +++++- 5 files changed, 391 insertions(+), 34 deletions(-) create mode 100644 src/pytest_fly/pytest_runner/coverage_aggregator.py create mode 100644 tests/test_coverage_aggregator.py diff --git a/src/pytest_fly/gui/coverage_tab/coverage_tab.py b/src/pytest_fly/gui/coverage_tab/coverage_tab.py index 947e027..bba2e39 100644 --- a/src/pytest_fly/gui/coverage_tab/coverage_tab.py +++ b/src/pytest_fly/gui/coverage_tab/coverage_tab.py @@ -2,17 +2,18 @@ Coverage tab — displays a step-function line chart of combined code coverage over time. """ +import time from pathlib import Path -from coverage.exceptions import CoverageException -from PySide6.QtCore import QPointF, Qt +from PySide6.QtCore import QPointF, Qt, QTimer from PySide6.QtGui import QBrush, QPainter, QPen, QPolygonF from PySide6.QtWidgets import QGroupBox, QHBoxLayout, QMessageBox, QPushButton, QSizePolicy, QVBoxLayout, QWidget from ...colors import COVERAGE_FILL_COLOR, COVERAGE_LINE_COLOR from ...interfaces import PytestRunnerState from ...logger import get_logger -from ...pytest_runner.coverage import calculate_coverage +from ...preferences import get_pref +from ...pytest_runner.coverage_aggregator import CoverageAggregator from ...tick_data import TickData from ..charts import paint_chart_frame from ..graph_tab.time_axis import Y_GRID_PCTS, TimeAxisMapping @@ -25,6 +26,9 @@ # the live tracker's "current" identifier so clicking the button never races with the # periodic coverage recalculation writing to the same combined data file. _HTML_REPORT_IDENTIFIER = "html_report" +_REPORT_POLL_INTERVAL_MS = 250 +_VIEW_BUTTON_TEXT = "View HTML Report" +_GENERATING_BUTTON_TEXT = "Generating report…" class _CoverageChart(QWidget): @@ -161,7 +165,7 @@ def __init__(self, data_dir: Path | None = None): if data_dir is not None: button_row = QHBoxLayout() button_row.addStretch() - self.view_report_button = QPushButton("View HTML Report") + self.view_report_button = QPushButton(_VIEW_BUTTON_TEXT) self.view_report_button.setToolTip("Generate and open the detailed line-by-line HTML coverage report in your browser.") self.view_report_button.setEnabled(False) # enabled once there is coverage data to report self.view_report_button.clicked.connect(self._on_view_report) @@ -171,21 +175,57 @@ def __init__(self, data_dir: Path | None = None): self.chart = _CoverageChart() layout.addWidget(self.chart, stretch=1) + # In-flight HTML report generation (a child process) and the timer that polls it. + self._report_child: CoverageAggregator | None = None + self._report_started: float = 0.0 + self._report_poll_timer = QTimer(self) + self._report_poll_timer.setInterval(_REPORT_POLL_INTERVAL_MS) + self._report_poll_timer.timeout.connect(self._poll_report_child) + def _on_view_report(self) -> None: """Generate a fresh HTML coverage report from the current data and open it. - Failures are shown to the user (a warning dialog), not just logged — previously a - failed generation silently opened a stale report, and a missing report opened nothing. + Generation runs in a child process (``coverage.html_report()`` re-parses the whole PUT + and has crashed the interpreter natively) and is polled from a timer, so the GUI stays + responsive and a crash in the child is a dialog, not the end of the session. Failures + are shown to the user — previously a failed generation silently opened a stale report. """ - if self._data_dir is None: + if self._data_dir is None or self._report_child is not None: + return + self._report_child = CoverageAggregator(_HTML_REPORT_IDENTIFIER, self._data_dir, write_report=True) + self._report_child.start() + self._report_started = time.monotonic() + if self.view_report_button is not None: + self.view_report_button.setEnabled(False) + self.view_report_button.setText(_GENERATING_BUTTON_TEXT) + self._report_poll_timer.start() + + def _poll_report_child(self) -> None: + """Timer slot: finish up once the report child exits (or times out), then open the report.""" + child = self._report_child + if child is None: + self._report_poll_timer.stop() return - try: - calculate_coverage(_HTML_REPORT_IDENTIFIER, self._data_dir, write_report=True) - except (OSError, ValueError, CoverageException) as e: - log.warning(f"HTML coverage report generation failed: {e}") - QMessageBox.warning(self, "Coverage report", f"Could not generate the HTML coverage report:\n{e}") + timed_out = time.monotonic() - self._report_started > get_pref().coverage_timeout_seconds + if child.is_alive() and not timed_out: return - if not ViewCoverage(self._data_dir).view(): + self._report_poll_timer.stop() + self._report_child = None + if self.view_report_button is not None: + self.view_report_button.setText(_VIEW_BUTTON_TEXT) + self.view_report_button.setEnabled(True) + if child.is_alive(): + child.terminate() + child.join() + log.warning("HTML coverage report generation timed out and was terminated") + QMessageBox.warning(self, "Coverage report", "HTML coverage report generation timed out.") + return + child.join() + if child.exitcode != 0 or child.result() is None: + log.warning(f"HTML coverage report generation failed (child exit code {child.exitcode})") + QMessageBox.warning(self, "Coverage report", f"Could not generate the HTML coverage report (process exit code {child.exitcode}). See the Log tab.") + return + if self._data_dir is not None and not ViewCoverage(self._data_dir).view(): QMessageBox.warning(self, "Coverage report", "No HTML coverage report was found to open.") def update_tick(self, tick: TickData) -> None: @@ -207,5 +247,5 @@ def update_tick(self, tick: TickData) -> None: self.chart.update_data(tick.coverage_history, tick.effective_min_time_stamp, tick.max_time_stamp, status_text, tick.covered_lines, tick.total_lines) # Only offer the report once there is coverage data to render. - if self.view_report_button is not None: + if self.view_report_button is not None and self._report_child is None: self.view_report_button.setEnabled(tick.total_lines > 0) diff --git a/src/pytest_fly/gui/coverage_tracker.py b/src/pytest_fly/gui/coverage_tracker.py index 86d7ca5..4a59256 100644 --- a/src/pytest_fly/gui/coverage_tracker.py +++ b/src/pytest_fly/gui/coverage_tracker.py @@ -6,9 +6,13 @@ instance and calls :meth:`CoverageTracker.update` on each refresh tick. Coverage combination is expensive — it re-combines every per-test ``.coverage`` -file and runs two full report passes over the PUT's sources — so it runs on a -dedicated background thread. :meth:`update` only *submits* work (coalescing: -the worker always processes the latest completed-test set) and +file and runs a full report pass over the PUT's sources — so it runs in a +short-lived child process (see :mod:`pytest_fly.pytest_runner.coverage_aggregator`) +driven from a dedicated background thread. A child that crashes natively, as +``coverage.report()`` has done inside the GUI process, is a logged warning rather +than the end of the run. :meth:`update` only *submits* work (coalescing: the +worker always processes the latest completed-test set, and no more often than the +``coverage_refresh_seconds`` preference while the run is active) and :meth:`apply_to_tick` publishes the most recently finished results, so the GUI tick never blocks on coverage. """ @@ -22,7 +26,9 @@ from ..file_util import sanitize_test_name from ..interfaces import PytestRunnerState from ..logger import get_logger -from ..pytest_runner.coverage import COVERAGE_READ_ERRORS, calculate_coverage +from ..preferences import get_pref +from ..pytest_runner.coverage import COVERAGE_READ_ERRORS +from ..pytest_runner.coverage_aggregator import aggregate_coverage from ..tick_data import TickData log = get_logger() @@ -32,10 +38,15 @@ class CoverageTracker: """Maintains cumulative coverage state and updates it when new tests finish. :param data_dir: Application data directory containing the ``coverage/`` subdirectory. + :param refresh_seconds: minimum seconds between recalculations while the run is active + (``0`` = after every completion); ``None`` reads the preference on each tick. + :param timeout_seconds: aggregation child-process timeout; ``None`` reads the preference. """ - def __init__(self, data_dir: Path): + def __init__(self, data_dir: Path, refresh_seconds: float | None = None, timeout_seconds: float | None = None): self._data_dir = data_dir + self._refresh_seconds = refresh_seconds + self._timeout_seconds = timeout_seconds self._last_run_guid: str | None = None self._worker: Thread | None = None self._work_available = Event() @@ -46,6 +57,7 @@ def __init__(self, data_dir: Path): self._submitted_completed: set[str] = set() # last completed-test set submitted, to skip no-change ticks self._current_run_start: float | None = None self._calculating = False + self._last_calculation_time: float | None = None # monotonic; drives the refresh-interval hold in update() self._generation = 0 # bumped by handle_new_run so an in-flight calculation for a prior run is discarded self._coverage_history: list[tuple[float, float]] = [] self._per_test_coverage: dict[str, float] = {} @@ -68,6 +80,7 @@ def handle_new_run(self, current_guid: str | None) -> None: self._generation += 1 self._pending_completed = None self._submitted_completed = set() + self._last_calculation_time = None self._coverage_history = [] self._per_test_coverage = {} self._covered_lines = 0 @@ -76,17 +89,30 @@ def handle_new_run(self, current_guid: str | None) -> None: def update(self, tick: TickData) -> None: """Submit a recalculation to the worker when the completed-test set changed. + While the run is still active, a new set is *held* (kept pending, newest wins) until + ``refresh_seconds`` have passed since the last calculation — per-test resolution on a + chart that refreshes every few seconds is not worth re-parsing the whole PUT each time. + Once no test is running or queued the hold is lifted, so the trailing recalculation after + the last completion always happens and the final number is never stale. + :param tick: Pre-computed data for this refresh cycle. """ + states = [rs.get_state() for rs in tick.run_states.values()] current_completed = {name for name, rs in tick.run_states.items() if rs.get_state() in (PytestRunnerState.PASS, PytestRunnerState.FAIL)} + run_active = any(state in (PytestRunnerState.RUNNING, PytestRunnerState.QUEUED) for state in states) if not current_completed: return with self._lock: - if current_completed == self._submitted_completed: + if current_completed != self._submitted_completed: + self._submitted_completed = set(current_completed) + self._pending_completed = set(current_completed) + self._current_run_start = tick.current_run_start + if self._pending_completed is None: return - self._submitted_completed = set(current_completed) - self._pending_completed = set(current_completed) - self._current_run_start = tick.current_run_start + if run_active and self._last_calculation_time is not None: + refresh_seconds = self._refresh_seconds if self._refresh_seconds is not None else get_pref().coverage_refresh_seconds + if time.monotonic() - self._last_calculation_time < refresh_seconds: + return # held: the pending set stays queued for a later tick self._ensure_worker() self._work_available.set() @@ -147,12 +173,16 @@ def _calculate(self, completed: set[str], generation: int, run_start: float | No """Recalculate combined and per-test coverage for *completed* and publish the results. Results are discarded if a new run started (generation changed) while computing. + Aggregation runs in a child process; if it crashes, hangs, or returns nothing, the + previously published values stand and this pass is skipped. """ - try: - coverage_pct, covered_lines, total_lines = calculate_coverage("current", self._data_dir, write_report=False) - except COVERAGE_READ_ERRORS as e: - log.warning(f"coverage calculation failed: {e}") + timeout = self._timeout_seconds if self._timeout_seconds is not None else get_pref().coverage_timeout_seconds + result = aggregate_coverage("current", self._data_dir, write_report=False, timeout=timeout) + with self._lock: + self._last_calculation_time = time.monotonic() # counts failed attempts too, so a crashing child is not re-spawned every tick + if result is None: return + coverage_pct, covered_lines, total_lines = result # Recompute per-test coverage for ALL completed tests since the denominator # (total_lines) may have changed as new tests discover new source files. diff --git a/src/pytest_fly/pytest_runner/coverage_aggregator.py b/src/pytest_fly/pytest_runner/coverage_aggregator.py new file mode 100644 index 0000000..f8957ee --- /dev/null +++ b/src/pytest_fly/pytest_runner/coverage_aggregator.py @@ -0,0 +1,74 @@ +"""Out-of-process coverage aggregation. + +:func:`pytest_fly.pytest_runner.coverage.calculate_coverage` combines every per-test +``.coverage`` file and runs ``coverage.report()`` over the whole program under test — a large +parse-and-teardown that has crashed the interpreter natively (access violation inside +``python.dll``) while running on a thread inside the GUI process, taking the orchestrator and +the run down with it. + +Nothing about that work needs to be in-process: it reads files and returns three numbers. So it +runs in a short-lived spawn child, like :class:`ProcessMonitor` and :class:`GetTests`. A child +that dies or hangs is a logged warning and the last good values stand; the run continues. +""" + +import time +from multiprocessing import Process, Queue +from pathlib import Path +from queue import Empty + +from ..faults import enable_faulthandler +from ..logger import EVENT_EXTRA, configure_child_logger, get_logger +from .coverage import calculate_coverage + +log = get_logger() + +CoverageResult = tuple[float | None, int, int] # (coverage 0.0-1.0 or None, covered statements, total statements) + + +class CoverageAggregator(Process): + """Spawn child that runs :func:`calculate_coverage` once and reports the result on a queue.""" + + def __init__(self, test_identifier: str, coverage_parent_directory: Path, write_report: bool) -> None: + super().__init__(name="coverage_aggregator", daemon=True) + self._test_identifier = test_identifier + self._coverage_parent_directory = coverage_parent_directory + self._write_report = write_report + self._result_queue: Queue = Queue() + + def run(self) -> None: + configure_child_logger("coverage_aggregator.log") + enable_faulthandler() # reads PYTEST_FLY_FAULTHANDLER from the inherited environment + result = calculate_coverage(self._test_identifier, self._coverage_parent_directory, self._write_report) + self._result_queue.put(result) + + def result(self) -> CoverageResult | None: + """The child's result, or ``None`` if it produced none (crashed, killed, or still running).""" + try: + return self._result_queue.get_nowait() + except Empty: + return None + + +def aggregate_coverage(test_identifier: str, coverage_parent_directory: Path, write_report: bool, timeout: float) -> CoverageResult | None: + """Run :func:`calculate_coverage` in a child process and wait for it. + + :param timeout: seconds before the child is terminated as hung. + :return: the coverage result, or ``None`` when the child crashed, was killed, or timed out — + never raises for those; the caller keeps its last good values. + """ + start = time.monotonic() + child = CoverageAggregator(test_identifier, coverage_parent_directory, write_report) + child.start() + child.join(timeout) + if child.is_alive(): + child.terminate() + child.join() + log.warning(f"coverage aggregation timed out after {timeout:.0f}s and was terminated (pid {child.pid})", extra=EVENT_EXTRA) + return None + result = child.result() + if child.exitcode != 0 or result is None: + # A negative exit code is a signal (native crash); see faulthandler-.log for the stack. + log.warning(f"coverage aggregation process {child.pid} exited with code {child.exitcode} without a result — keeping previous coverage values", extra=EVENT_EXTRA) + return None + log.debug(f"coverage aggregation took {time.monotonic() - start:.1f}s (pid {child.pid})") + return result diff --git a/tests/test_coverage_aggregator.py b/tests/test_coverage_aggregator.py new file mode 100644 index 0000000..185fa4d --- /dev/null +++ b/tests/test_coverage_aggregator.py @@ -0,0 +1,173 @@ +"""Tests for out-of-process coverage aggregation and the single-pass report equivalence.""" + +import io +import logging +import time +from pathlib import Path + +import pytest +from coverage import CoverageData + +from pytest_fly.file_util import sanitize_test_name +from pytest_fly.gui.coverage_tracker import CoverageTracker +from pytest_fly.interfaces import PyTestFlyExitCode, PytestProcessInfo, PytestRunnerState +from pytest_fly.pytest_runner import coverage_aggregator +from pytest_fly.pytest_runner.coverage import PytestFlyCoverage, _parse_report_totals, calculate_coverage +from pytest_fly.pytest_runner.coverage_aggregator import CoverageAggregator, aggregate_coverage +from pytest_fly.pytest_runner.run_state import PytestRunState +from pytest_fly.tick_data import TickData + + +def _write_fixture(data_dir: Path, test_name: str, lines: list[int]) -> Path: + """A 4-statement source file plus one per-test coverage file covering *lines* of it.""" + source = data_dir / "m.py" + source.write_text("a = 1\nb = 2\nc = 3\nd = 4\n") + coverage_dir = data_dir / "coverage" + coverage_dir.mkdir(exist_ok=True) + data_file = coverage_dir / f"{sanitize_test_name(test_name)}.coverage" + data = CoverageData(basename=str(data_file)) + data.add_lines({str(source.resolve()): lines}) + data.write() + return source + + +def test_single_report_pass_matches_parsed_totals(tmp_path): + """cov.report()'s return value and the parsed TOTAL line agree — the basis for the single-pass change.""" + _write_fixture(tmp_path, "tests/test_one.py", [1, 2, 3]) + value, covered, total = calculate_coverage("equiv", tmp_path, write_report=False) + assert total == 4 and covered == 3 + assert value == pytest.approx(covered / total, abs=0.01) + + # Pin the equivalence directly against coverage's own API on the same combined file. + cov = PytestFlyCoverage(tmp_path / "combined" / "unused.combined") + cov.combine([str(p) for p in (tmp_path / "coverage").glob("*.coverage")], keep=True) + buffer = io.StringIO() + returned_pct = cov.report(ignore_errors=True, file=buffer) + statements, missing = _parse_report_totals(buffer.getvalue()) + assert returned_pct == pytest.approx((statements - missing) / statements * 100.0, abs=1.0) + + +def test_child_returns_same_numbers_as_in_process(tmp_path): + _write_fixture(tmp_path, "tests/test_one.py", [1, 2]) + in_process = calculate_coverage("current", tmp_path, write_report=False) + out_of_process = aggregate_coverage("current", tmp_path, write_report=False, timeout=120) + assert out_of_process == in_process + assert out_of_process[1:] == (2, 4) + + +def test_child_with_no_data_returns_empty_result(tmp_path): + assert aggregate_coverage("current", tmp_path, write_report=False, timeout=120) == (None, 0, 0) + + +class _DyingAggregator(CoverageAggregator): + """Child that dies from a real segfault instead of reporting.""" + + def run(self) -> None: + import faulthandler + + faulthandler._sigsegv() # noqa: SLF001 — documented test hook + + +class _HangingAggregator(CoverageAggregator): + """Child that never reports.""" + + def run(self) -> None: + time.sleep(600) + + +def test_dead_child_is_a_warning_not_an_exception(tmp_path, monkeypatch, caplog): + monkeypatch.setattr(coverage_aggregator, "CoverageAggregator", _DyingAggregator) + with caplog.at_level(logging.WARNING): + assert aggregate_coverage("current", tmp_path, write_report=False, timeout=120) is None + assert any("coverage aggregation process" in r.message and "exited" in r.message for r in caplog.records) + + +def test_hung_child_is_terminated(tmp_path, monkeypatch, caplog): + monkeypatch.setattr(coverage_aggregator, "CoverageAggregator", _HangingAggregator) + with caplog.at_level(logging.WARNING): + start = time.monotonic() + assert aggregate_coverage("current", tmp_path, write_report=False, timeout=3) is None + assert time.monotonic() - start < 60 + assert any("timed out" in r.message for r in caplog.records) + + +# --- tracker behaviour on top of the child --------------------------------------------------- + + +def _tick(data_dir: Path, completed: list[str], running: list[str] = ()) -> TickData: + run_states = {} + for name in completed: + info = PytestProcessInfo(run_guid="run-1", name=name, pid=1, exit_code=PyTestFlyExitCode.OK, output="", time_stamp=100.0) + run_states[name] = PytestRunState([info]) + for name in running: + info = PytestProcessInfo(run_guid="run-1", name=name, pid=1, exit_code=PyTestFlyExitCode.NONE, output="", time_stamp=100.0) + run_states[name] = PytestRunState([info]) + tick = TickData(process_infos=[], run_states=run_states, current_run_start=50.0) + for name, rs in run_states.items(): + assert rs.get_state() in (PytestRunnerState.PASS, PytestRunnerState.RUNNING), (name, rs.get_state()) + return tick + + +def test_tracker_keeps_last_good_values_when_child_dies(tmp_path, monkeypatch): + _write_fixture(tmp_path, "tests/test_one.py", [1, 2, 3, 4]) + tracker = CoverageTracker(tmp_path, refresh_seconds=0.0, timeout_seconds=120.0) + tracker.update(_tick(tmp_path, ["tests/test_one.py"])) + assert tracker.wait_for_pending() + first = TickData(process_infos=[]) + tracker.apply_to_tick(first) + assert first.total_lines == 4 and first.covered_lines == 4 + + monkeypatch.setattr(coverage_aggregator, "CoverageAggregator", _DyingAggregator) + _write_fixture(tmp_path, "tests/test_two.py", [1]) + tracker.update(_tick(tmp_path, ["tests/test_one.py", "tests/test_two.py"])) + assert tracker.wait_for_pending() + second = TickData(process_infos=[]) + tracker.apply_to_tick(second) + assert (second.total_lines, second.covered_lines) == (first.total_lines, first.covered_lines) + assert second.coverage_history == first.coverage_history + + +def test_tracker_rate_limits_and_processes_newest_set(tmp_path, monkeypatch): + """Rapid updates inside the refresh window yield one calculation, and for the newest set.""" + processed: list[set[str]] = [] + + def _record(completed, generation, run_start): + processed.append(set(completed)) + with tracker._lock: + tracker._last_calculation_time = time.monotonic() + + tracker = CoverageTracker(tmp_path, refresh_seconds=30.0, timeout_seconds=120.0) + monkeypatch.setattr(tracker, "_calculate", _record) + + # First calculation goes straight through (nothing calculated yet). + tracker.update(_tick(tmp_path, ["t1"], running=["t9"])) + assert tracker.wait_for_pending() + assert processed == [{"t1"}] + + # Inside the window and the run is still active: held, not calculated. + tracker.update(_tick(tmp_path, ["t1", "t2"], running=["t9"])) + tracker.update(_tick(tmp_path, ["t1", "t2", "t3"], running=["t9"])) + time.sleep(0.2) + assert processed == [{"t1"}] + assert tracker._pending_completed == {"t1", "t2", "t3"} + + # Run finished (nothing running/queued): the hold lifts and the NEWEST set is processed. + tracker.update(_tick(tmp_path, ["t1", "t2", "t3", "t9"])) + assert tracker.wait_for_pending() + assert processed == [{"t1"}, {"t1", "t2", "t3", "t9"}] + + +def test_tracker_zero_refresh_means_every_completion(tmp_path, monkeypatch): + processed: list[set[str]] = [] + + def _record(completed, generation, run_start): + processed.append(set(completed)) + with tracker._lock: + tracker._last_calculation_time = time.monotonic() + + tracker = CoverageTracker(tmp_path, refresh_seconds=0.0, timeout_seconds=120.0) + monkeypatch.setattr(tracker, "_calculate", _record) + for n in range(1, 4): + tracker.update(_tick(tmp_path, [f"t{i}" for i in range(n)], running=["t9"])) + assert tracker.wait_for_pending() + assert len(processed) == 3 diff --git a/tests/test_coverage_tab_report.py b/tests/test_coverage_tab_report.py index a5bcfe2..10ebf4a 100644 --- a/tests/test_coverage_tab_report.py +++ b/tests/test_coverage_tab_report.py @@ -1,5 +1,6 @@ """Tests for the CoverageTab 'View HTML Report' button wiring.""" +import time import webbrowser from pathlib import Path from tempfile import TemporaryDirectory @@ -33,14 +34,46 @@ def test_button_enables_only_when_coverage_data_present(app): assert not tab.view_report_button.isEnabled() -def test_view_report_generates_html_and_opens_viewer(app, monkeypatch): - """Clicking generates a fresh HTML report (write_report=True) then opens it.""" - calls = {} +def _pump_until_report_done(app, tab: CoverageTab, timeout: float = 60.0) -> None: + """Drive the Qt event loop until the tab's report child has been reaped by the poll timer.""" + deadline = time.monotonic() + timeout + while tab._report_child is not None and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.02) + assert tab._report_child is None, "HTML report child did not finish" + + +class _FakeAggregator: + """Stand-in for CoverageAggregator: records its arguments and completes immediately.""" + + calls: dict = {} + + def __init__(self, identifier, data_dir, write_report): + _FakeAggregator.calls["calc"] = (identifier, data_dir, write_report) + self.exitcode = 0 + self.pid = 0 + + def start(self): + pass + + def is_alive(self): + return False - def _fake_calculate(identifier, data_dir, write_report): - calls["calc"] = (identifier, data_dir, write_report) + def join(self, timeout=None): + pass + + def terminate(self): + pass + + def result(self): return 0.5, 50, 100 + +def test_view_report_generates_html_and_opens_viewer(app, monkeypatch): + """Clicking generates a fresh HTML report (write_report=True) in the aggregator child, then opens it.""" + calls = _FakeAggregator.calls + calls.clear() + class _FakeViewer: def __init__(self, data_dir): calls["viewer_dir"] = data_dir @@ -49,23 +82,29 @@ def view(self): calls["viewed"] = True return True # report found and opened — no warning dialog - monkeypatch.setattr(coverage_tab_module, "calculate_coverage", _fake_calculate) + monkeypatch.setattr(coverage_tab_module, "CoverageAggregator", _FakeAggregator) monkeypatch.setattr(coverage_tab_module, "ViewCoverage", _FakeViewer) with TemporaryDirectory() as tmp: data_dir = Path(tmp) tab = CoverageTab(data_dir) tab._on_view_report() + assert not tab.view_report_button.isEnabled() # disabled while generating + _pump_until_report_done(app, tab) assert calls["calc"][0] == "html_report" # dedicated identifier, not the live tracker's "current" assert calls["calc"][1] == data_dir assert calls["calc"][2] is True # write_report assert calls["viewer_dir"] == data_dir assert calls["viewed"] is True + assert tab.view_report_button.text() == "View HTML Report" def test_view_report_graceful_with_no_coverage_data(app, monkeypatch): - """With no coverage data on disk the handler warns the user (no browser, no exception).""" + """With no coverage data on disk the handler warns the user (no browser, no exception). + + Uses the real aggregator child process end to end. + """ opened = [] warnings = [] monkeypatch.setattr(webbrowser, "open", lambda uri: opened.append(uri)) @@ -74,5 +113,6 @@ def test_view_report_graceful_with_no_coverage_data(app, monkeypatch): with TemporaryDirectory() as tmp: tab = CoverageTab(Path(tmp)) tab._on_view_report() # empty data dir -> no report produced + _pump_until_report_done(app, tab) assert opened == [] assert len(warnings) == 1 # the user is told there was no report to open From 52d4458732d677de633eb3b93a56dc041ff60279 Mon Sep 17 00:00:00 2001 From: JamesAbel Date: Sun, 23 Aug 2026 09:57:14 -0700 Subject: [PATCH 4/4] =?UTF-8?q?Configuration=20tab:=20Crash=20Diagnostics?= =?UTF-8?q?=20group=20and=20coverage=20refresh/timeout=20fields=20?= =?UTF-8?q?=E2=80=94=20v0.9.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crash Diagnostics group (faulthandler checkbox; on Windows the WER LocalDumps status, folder/type/count, Configure (UAC) / Remove / Copy command buttons and a machine-wide caution). Coverage Refresh and Coverage Timeout fields next to Refresh Rate. Restore-defaults wiring, CLAUDE.md architecture notes, version 0.9.0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018j7yAH7CooFHFLEANWM5vM --- CLAUDE.md | 8 +- pyproject.toml | 2 +- .../gui/configuration_tab/configuration.py | 232 +++++++++++++++++- tests/test_configuration.py | 37 +++ 4 files changed, 276 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2dce59d..0803a3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ pip install -r requirements-dev.txt ### GUI layer (`src/pytest_fly/gui/`) - `gui_main.py` — `FlyAppMainWindow`: 8-tab Qt window with a periodic timer (default 3 s) that pulls updates from the runner and refreshes all tabs. +- `coverage_tracker.py` — `CoverageTracker`: submits coverage recalculation to a worker thread that runs the aggregator child; rate-limited by the Coverage Refresh preference while the run is active, with a final pass once nothing is running. - Tabs: `run_tab/` (run/stop controls, status, system metrics, failed tests, live output), `graph_tab/` (time-based progress chart), `table_tab/` (per-test status grid), `coverage_tab/` (coverage-over-time chart), `history_tab/` (recent-run summaries — run times, pass/fail statistics, failed-test lists; multi-select rows copy to the clipboard; run count set by the History Run Limit preference), `log_tab/` (live application event log — admission-gate, resource-guard, and stall-watchdog events, each line date/time-prefixed; default view shows tagged `EVENT_EXTRA` events + warnings, Verbose shows all INFO+), `configuration_tab/` (parallelism, thresholds, gates), `about_tab/`. ### Core runner (`src/pytest_fly/pytest_runner/`) @@ -62,9 +63,14 @@ pip install -r requirements-dev.txt - `process_monitor.py` — `ProcessMonitor` subprocess: samples CPU/memory of the test process tree; `SubtreeCpuSampler` (shared persistent-handle CPU sampling). - `system_monitor.py` — `SystemMonitor` subprocess: system-wide CPU/memory/commit/disk/network sampling for the Run tab charts. - `commit_memory.py` — Windows commit-charge readers and psutil subtree helpers. -- `coverage.py` — merges per-process coverage data. +- `coverage.py` — merges per-process coverage data (one `cov.report()` pass yields both the percentage and the TOTAL line). +- `coverage_aggregator.py` — `CoverageAggregator` spawn child + `aggregate_coverage()`: runs `calculate_coverage()` out of the GUI process (it has crashed the interpreter natively); a dead/hung child is a logged warning, not a crash. - `ordering.py` — applies the user's test-ordering aspects; `live_output.py` — per-test live-output file paths. +### Crash diagnostics +- `faults.py` — arms `faulthandler` (parent + every spawn child, via the `PYTEST_FLY_FAULTHANDLER` env var) into `.pytest-fly/logs/faulthandler-.log`; `report_previous_crashes()` surfaces non-empty dumps at the next launch and archives them as `faulthandler-crash--.log`. +- `platform/wer.py` — Windows Error Reporting LocalDumps: read-only status, the elevated (UAC) configure/remove commands offered from the Configuration tab, and a startup sweep of new `*.dmp` files. Machine-wide for all `python.exe`; never applied silently. + ### Persistence - `db/db.py` — stores `PytestProcessInfo` records (status, timing, resource usage) — the foundation for RESUME mode. Two access classes: `PytestProcessInfoDB` (read/write via **msqlite**, whose context manager holds the DB's EXCLUSIVE lock) and `PytestProcessInfoReader` (read-only, lock-free WAL snapshot reads — required for the GUI thread and monitor threads so they never contend with test-process writers). - `preferences.py` — persists user settings (window geometry, parallelism count, utilization thresholds, run mode) via the **pref** library. diff --git a/pyproject.toml b/pyproject.toml index 13c426c..80f5b3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pytest-fly" description = "pytest runner and observer" -version = "0.8.1" +version = "0.9.0" readme = "README.md" requires-python = ">=3.12" authors = [ diff --git a/src/pytest_fly/gui/configuration_tab/configuration.py b/src/pytest_fly/gui/configuration_tab/configuration.py index 7983d9e..e603d8e 100644 --- a/src/pytest_fly/gui/configuration_tab/configuration.py +++ b/src/pytest_fly/gui/configuration_tab/configuration.py @@ -6,9 +6,10 @@ from collections.abc import Callable from pathlib import Path -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QDoubleValidator, QIntValidator, QValidator from PySide6.QtWidgets import ( + QApplication, QCheckBox, QComboBox, QFileDialog, @@ -30,7 +31,18 @@ from pytest_fly.interfaces import RunMode from pytest_fly.logger import get_logger from pytest_fly.paths import get_default_data_dir +from pytest_fly.platform import is_windows from pytest_fly.platform.platform_info import get_performance_core_count +from pytest_fly.platform.wer import ( + DEFAULT_IMAGE_NAME, + DUMP_TYPE_FULL, + DUMP_TYPE_MINIDUMP, + apply_wer_local_dumps_elevated, + default_wer_dump_folder, + read_wer_local_dumps, + remove_wer_local_dumps_elevated, + wer_configure_command, +) from pytest_fly.preferences import ( TIME_UNITS, auto_force_stop_on_stall_default, @@ -38,10 +50,13 @@ commit_gate_enabled_default, commit_gate_threshold_default, commit_warning_threshold_default, + coverage_refresh_seconds_default, + coverage_timeout_seconds_default, cpu_active_epsilon_default, cpu_gate_enabled_default, cpu_gate_threshold_default, duration_to_seconds, + faulthandler_enabled_default, get_active_put_path, get_pref, graph_font_size_default, @@ -62,6 +77,8 @@ tooltip_line_limit_default, utilization_high_threshold_default, utilization_low_threshold_default, + wer_dump_count_default, + wer_dump_type_default, ) from pytest_fly.project_info import get_project_info @@ -73,6 +90,9 @@ minimum_graph_font_size = 6 minimum_log_tab_line_limit = 100 minimum_history_run_limit = 1 +minimum_coverage_timeout_seconds = 10.0 +minimum_wer_dump_count = 1 +_wer_dump_type_names = {DUMP_TYPE_MINIDUMP: "Minidump (stacks + modules, a few MB)", DUMP_TYPE_FULL: "Full (includes heap, can be many GB)"} def _add_labeled_lineedit( @@ -288,6 +308,35 @@ def __init__(self): layout.addWidget(QLabel("")) # space + self.coverage_refresh_seconds_lineedit = _add_labeled_lineedit( + layout, + f"Coverage Refresh (seconds, {_format_number(coverage_refresh_seconds_default)} default, 0 = every completion)", + _format_number(pref.coverage_refresh_seconds), + QDoubleValidator(), + self.update_coverage_refresh_seconds, + char_width=6, + tooltip=( + "How often combined coverage is recomputed during a run. Aggregation re-parses the\n" + "whole program under test, so lower values cost real CPU. A final recalculation always\n" + "runs once the last test completes." + ), + ) + + self.coverage_timeout_seconds_lineedit = _add_labeled_lineedit( + layout, + f"Coverage Timeout (seconds, min {_format_number(minimum_coverage_timeout_seconds)}, {_format_number(coverage_timeout_seconds_default)} default)", + _format_number(pref.coverage_timeout_seconds), + QDoubleValidator(), + self.update_coverage_timeout_seconds, + char_width=6, + tooltip=( + "Coverage aggregation runs in a separate process; one that runs longer than this is terminated\n" + "and the previous coverage values are kept. A full report over a large project can be slow." + ), + ) + + layout.addWidget(QLabel("")) # space + utilization_tooltip = "Colors the Table tab's CPU column: red above the high threshold, yellow above the low\nthreshold. Values are clamped into 0.0-1.0." high_label = f"High Utilization Threshold (0.0-1.0, {utilization_high_threshold_default} default)" self.utilization_high_threshold_lineedit = _add_labeled_lineedit( @@ -679,6 +728,8 @@ def __init__(self): right_column.addWidget(resource_guard_group) + right_column.addWidget(self._build_crash_diagnostics_group(pref)) + # Expert group — settings most users should not need to change. Lives at the bottom of # the right column (last position, to de-emphasize) rather than the left column, which # is the taller of the two and drives the tab's overall height. @@ -702,6 +753,173 @@ def __init__(self): right_column.addWidget(expert_group) right_column.addStretch() + def _build_crash_diagnostics_group(self, pref) -> QGroupBox: + """Crash Diagnostics group: faulthandler (all platforms) and WER LocalDumps (Windows only). + + WER configuration lives under HKLM and is machine-wide for every ``python.exe``, so it is + only ever *offered* — applied through a UAC prompt, or copied for the user's own elevated + shell — and the displayed state is always re-read from the registry, never assumed. + """ + group = QGroupBox("Crash Diagnostics") + group.setToolTip("What gets left behind when a pytest-fly process dies from a native fault (access violation, abort, ...).") + group_layout = QVBoxLayout() + group.setLayout(group_layout) + + self.faulthandler_enabled_checkbox = _add_pref_checkbox( + group_layout, + "Python Fault Handler (default: on)", + pref.faulthandler_enabled, + self.update_faulthandler_enabled, + tooltip=( + "Write every thread's Python stack to .pytest-fly/logs/faulthandler-.log when a\n" + "pytest-fly process dies from a fatal signal. Cheap; leave on. Any dump from a previous\n" + "session is reported in the Log tab at the next launch. Applies at the next launch." + ), + ) + + self.wer_status_label: QLabel | None = None + if not is_windows(): + return group + + group_layout.addWidget(QLabel("")) # space + group_layout.addWidget(QLabel("Windows crash dumps (WER LocalDumps)")) + self.wer_status_label = QLabel("") + self.wer_status_label.setWordWrap(True) + group_layout.addWidget(self.wer_status_label) + + folder_label = QLabel("Dump Folder (empty = workspace default)") + folder_label.setToolTip(f"Where Windows writes crash dumps. Empty uses {default_wer_dump_folder()}.") + group_layout.addWidget(folder_label) + self.wer_dump_folder_lineedit = QLineEdit() + self.wer_dump_folder_lineedit.setText(pref.wer_dump_folder) + self.wer_dump_folder_lineedit.setPlaceholderText(str(default_wer_dump_folder())) + self.wer_dump_folder_lineedit.textChanged.connect(self.update_wer_dump_folder) + group_layout.addWidget(self.wer_dump_folder_lineedit) + + group_layout.addWidget(QLabel("Dump Type")) + self.wer_dump_type_combo = QComboBox() + for dump_type, name in _wer_dump_type_names.items(): + self.wer_dump_type_combo.addItem(name, dump_type) + self.wer_dump_type_combo.setCurrentIndex(max(0, self.wer_dump_type_combo.findData(pref.wer_dump_type))) + self.wer_dump_type_combo.setToolTip( + "A minidump carries thread stacks, registers, and the module list — enough to identify the\n" + "faulting native frame. A full dump also carries the heap (needed to walk Python frames out\n" + "of the dump), but for a large run that is many GB per crash. The Python fault handler above\n" + "already provides the Python frames, so minidump is the recommended default." + ) + self.wer_dump_type_combo.currentIndexChanged.connect(self.update_wer_dump_type) + group_layout.addWidget(self.wer_dump_type_combo) + + self.wer_dump_count_lineedit = _add_labeled_lineedit( + group_layout, + f"Dump Count ({wer_dump_count_default} default)", + str(pref.wer_dump_count), + QIntValidator(), + self.update_wer_dump_count, + tooltip="How many dumps Windows keeps before evicting the oldest.", + ) + + button_row = QHBoxLayout() + button_row.setAlignment(Qt.AlignmentFlag.AlignLeft) + configure_button = QPushButton("Configure (requires admin)...") + configure_button.setToolTip("Apply the settings above to the registry via a Windows UAC prompt, then re-read the result.") + configure_button.clicked.connect(self._on_wer_configure) + button_row.addWidget(configure_button) + remove_button = QPushButton("Remove...") + remove_button.setToolTip("Delete the LocalDumps entry for python.exe via a Windows UAC prompt (Windows goes back to discarding dumps).") + remove_button.clicked.connect(self._on_wer_remove) + button_row.addWidget(remove_button) + copy_button = QPushButton("Copy command") + copy_button.setToolTip("Copy the PowerShell command to the clipboard, to paste into your own elevated shell instead of accepting a UAC prompt from a GUI app.") + copy_button.clicked.connect(self._on_wer_copy_command) + button_row.addWidget(copy_button) + group_layout.addLayout(button_row) + + caution = QLabel("This is a machine-wide Windows setting for all python.exe processes, not just pytest-fly.") + caution.setWordWrap(True) + group_layout.addWidget(caution) + + self._refresh_wer_status() + return group + + def _refresh_wer_status(self) -> None: + """Re-read the registry and show the live LocalDumps state.""" + if self.wer_status_label is not None: + self.wer_status_label.setText(read_wer_local_dumps(DEFAULT_IMAGE_NAME).describe()) + + def _wer_settings(self) -> tuple[Path, int, int]: + """The (folder, count, type) triple from preferences, with the workspace default folder applied.""" + pref = get_pref() + folder = Path(pref.wer_dump_folder) if pref.wer_dump_folder else default_wer_dump_folder() + return folder, pref.wer_dump_count, pref.wer_dump_type + + def _on_wer_configure(self) -> None: + """Offer the elevated write; the status is driven by a registry re-read, never by the launch result.""" + folder, count, dump_type = self._wer_settings() + command = wer_configure_command(DEFAULT_IMAGE_NAME, folder, count, dump_type) + response = QMessageBox.question( + self, + "Configure Windows crash dumps", + f"Windows will prompt for administrator approval to write this machine-wide setting for ALL python.exe processes:\n\n{command}\n\nContinue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if response != QMessageBox.StandardButton.Yes: + return + if not apply_wer_local_dumps_elevated(DEFAULT_IMAGE_NAME, folder, count, dump_type): + QMessageBox.warning(self, "Configure Windows crash dumps", "The elevated command was not launched (administrator approval declined?).") + # The write happens in another process; poll the registry briefly so the label catches up. + self._schedule_wer_status_refresh() + + def _on_wer_remove(self) -> None: + """Offer the elevated delete of the LocalDumps subkey.""" + response = QMessageBox.question( + self, + "Remove Windows crash dump setting", + f"Windows will prompt for administrator approval to delete the LocalDumps entry for {DEFAULT_IMAGE_NAME}. Continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if response != QMessageBox.StandardButton.Yes: + return + if not remove_wer_local_dumps_elevated(DEFAULT_IMAGE_NAME): + QMessageBox.warning(self, "Remove Windows crash dump setting", "The elevated command was not launched (administrator approval declined?).") + self._schedule_wer_status_refresh() + + def _schedule_wer_status_refresh(self, attempts: int = 10, interval_ms: int = 1000) -> None: + """Re-read the registry a few times over the next seconds — the elevated write is asynchronous.""" + for n in range(1, attempts + 1): + QTimer.singleShot(n * interval_ms, self._refresh_wer_status) + + def _on_wer_copy_command(self) -> None: + """Put the configure command on the clipboard for the user's own elevated shell.""" + folder, count, dump_type = self._wer_settings() + QApplication.clipboard().setText(wer_configure_command(DEFAULT_IMAGE_NAME, folder, count, dump_type)) + + def update_faulthandler_enabled(self): + """Persist the faulthandler checkbox state.""" + self._set_bool_pref("faulthandler_enabled", self.faulthandler_enabled_checkbox) + + def update_wer_dump_folder(self, value: str): + """Persist the WER dump folder (empty = workspace default).""" + get_pref().wer_dump_folder = value.strip() + + def update_wer_dump_type(self, index: int): + """Persist the WER dump type chosen in the combo.""" + get_pref().wer_dump_type = int(self.wer_dump_type_combo.itemData(index)) + + def update_wer_dump_count(self, value: str): + """Persist the WER dump count (minimum 1).""" + self._set_int_pref("wer_dump_count", value, minimum=minimum_wer_dump_count) + + def update_coverage_refresh_seconds(self, value: str): + """Persist the coverage refresh interval (0 = recalculate after every completion).""" + self._set_float_pref("coverage_refresh_seconds", value, minimum=0.0) + + def update_coverage_timeout_seconds(self, value: str): + """Persist the coverage aggregation timeout (clamped to *minimum_coverage_timeout_seconds*).""" + self._set_float_pref("coverage_timeout_seconds", value, minimum=minimum_coverage_timeout_seconds) + # ------------------------------------------------------------------ # Preference-persistence helpers — shared by all the update_* slots below # ------------------------------------------------------------------ @@ -913,6 +1131,7 @@ def _apply_defaults(self) -> None: ("commit_gate_enabled", self.commit_gate_enabled_checkbox, commit_gate_enabled_default), ("cpu_gate_enabled", self.cpu_gate_enabled_checkbox, cpu_gate_enabled_default), ("resource_guard_enabled", self.resource_guard_enabled_checkbox, resource_guard_enabled_default), + ("faulthandler_enabled", self.faulthandler_enabled_checkbox, faulthandler_enabled_default), ("verbose", self.verbose_checkbox, False), ("perf_logging", self.perf_logging_checkbox, False), ] @@ -934,6 +1153,8 @@ def _apply_defaults(self) -> None: ("graph_font_size", self.graph_font_size_lineedit, graph_font_size_default), ("log_tab_line_limit", self.log_tab_line_limit_lineedit, log_tab_line_limit_default), ("history_run_limit", self.history_run_limit_lineedit, history_run_limit_default), + ("coverage_refresh_seconds", self.coverage_refresh_seconds_lineedit, coverage_refresh_seconds_default), + ("coverage_timeout_seconds", self.coverage_timeout_seconds_lineedit, coverage_timeout_seconds_default), ("cpu_active_epsilon", self.cpu_active_epsilon_lineedit, cpu_active_epsilon_default), ("max_descendant_processes", self.max_descendant_processes_lineedit, max_descendant_processes_default), ("commit_gate_threshold", self.commit_gate_threshold_lineedit, commit_gate_threshold_default), @@ -945,6 +1166,15 @@ def _apply_defaults(self) -> None: setattr(pref, pref_name, default) lineedit.setText(_format_number(default)) + # WER settings (Windows-only widgets). + pref.wer_dump_folder = "" + pref.wer_dump_type = wer_dump_type_default + pref.wer_dump_count = wer_dump_count_default + if self.wer_status_label is not None: + self.wer_dump_folder_lineedit.setText("") + self.wer_dump_type_combo.setCurrentIndex(max(0, self.wer_dump_type_combo.findData(wer_dump_type_default))) + self.wer_dump_count_lineedit.setText(str(wer_dump_count_default)) + # Stall windows: value + unit pairs. pref.stall_warn_value = stall_warn_value_default pref.stall_warn_unit = stall_warn_unit_default diff --git a/tests/test_configuration.py b/tests/test_configuration.py index e7293df..7cf3632 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -11,6 +11,7 @@ from pytest_fly.interfaces import OrderingAspect, RunMode from pytest_fly.paths import get_workspace_dir, init_workspace from pytest_fly.preferences import ( + coverage_refresh_seconds_default, cpu_gate_threshold_default, get_active_put_path, get_ordering_aspects_ordered, @@ -275,3 +276,39 @@ def test_ordering_widget_move_and_toggle(app): # Toggling a checkbox triggers _on_item_changed -> reorder + persist. widget._list.item(0).setCheckState(Qt.CheckState.Unchecked) widget._list.item(widget._list.count() - 1).setCheckState(Qt.CheckState.Checked) + + +def test_crash_diagnostics_group(app, monkeypatch): + """faulthandler + coverage fields persist; the WER widgets exist only on Windows.""" + cfg = Configuration() + cfg.faulthandler_enabled_checkbox.setChecked(False) + assert get_pref().faulthandler_enabled is False + cfg.faulthandler_enabled_checkbox.setChecked(True) + assert get_pref().faulthandler_enabled is True + + cfg.coverage_refresh_seconds_lineedit.setText("0") + assert get_pref().coverage_refresh_seconds == 0.0 + cfg.coverage_timeout_seconds_lineedit.setText("1") # below the minimum -> clamped + assert get_pref().coverage_timeout_seconds == configuration_module.minimum_coverage_timeout_seconds + + if configuration_module.is_windows(): + assert cfg.wer_status_label is not None + assert cfg.wer_status_label.text() # live registry state, whatever it is + cfg.wer_dump_count_lineedit.setText("0") + assert get_pref().wer_dump_count == 1 + cfg.wer_dump_folder_lineedit.setText(r" C:\dumps ") + assert get_pref().wer_dump_folder == r"C:\dumps" + else: + assert cfg.wer_status_label is None + + cfg._apply_defaults() + assert get_pref().faulthandler_enabled is True + assert get_pref().coverage_refresh_seconds == coverage_refresh_seconds_default + assert get_pref().wer_dump_folder == "" + + +def test_crash_diagnostics_group_hides_wer_off_windows(app, monkeypatch): + monkeypatch.setattr(configuration_module, "is_windows", lambda: False) + cfg = Configuration() + assert cfg.wer_status_label is None + assert not hasattr(cfg, "wer_dump_folder_lineedit")